mobucheeri commited on
Commit
08b3b18
·
1 Parent(s): 77c46ac

initial deployment

Browse files
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ data/raw/
2
+ data/processed/
3
+ .venv/
4
+ __pycache__/
5
+ *.pyc
6
+ .git/
7
+ notebooks/
8
+ extension/
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ *.pt
6
+ *.npy
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /code
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "api.app:app", "--host", "0.0.0.0", "--port", "7860"]
api/app.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ os.environ.setdefault("OMP_NUM_THREADS", "1")
3
+ os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
4
+ import sys
5
+ import re
6
+ import json
7
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8
+ import numpy as np
9
+ import torch
10
+ from fastapi import FastAPI, HTTPException
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from pydantic import BaseModel
13
+ from src.config import checkpoints, device, max_seq_len, data_processed, numeric_features
14
+
15
+ _model = None
16
+ _model_info = None
17
+ _tokenizer = None
18
+ _numeric_mean = None
19
+ _numeric_std = None
20
+ _threshold = 0.5
21
+
22
+ def load_model():
23
+ global _model, _model_info, _tokenizer, _numeric_mean, _numeric_std, _threshold
24
+ if _model is not None:
25
+ return
26
+ proc_path = os.path.join(data_processed, "processed.pt")
27
+ if os.path.exists(proc_path):
28
+ proc_data = torch.load(proc_path, weights_only=False)
29
+ _numeric_mean = proc_data.get("numeric_mean")
30
+ _numeric_std = proc_data.get("numeric_std")
31
+ info_path = os.path.join(checkpoints, "best_model_info.json")
32
+ if not os.path.exists(info_path):
33
+ raise FileNotFoundError("No trained model. Run: python src/train.py")
34
+ with open(info_path) as f:
35
+ _model_info = json.load(f)
36
+ name = _model_info["model_name"]
37
+ model_type = _model_info.get("model_type", "neural")
38
+ _threshold = float(_model_info.get("threshold", 0.5))
39
+ if model_type == "xgboost":
40
+ import xgboost as xgb
41
+ _model = xgb.XGBClassifier()
42
+ _model.load_model(os.path.join(checkpoints, f"{name}_best.json"))
43
+ _tokenizer = None
44
+ else:
45
+ from src.data import GloveVocab
46
+ _tokenizer = GloveVocab.load(os.path.join(checkpoints, "vocab.json"))
47
+ from src.models import BiGRU_LSTM, CNN_BiLSTM
48
+ _model = BiGRU_LSTM(vocab_size=_tokenizer.vocab_size) if name == "bigru_lstm" else CNN_BiLSTM(vocab_size=_tokenizer.vocab_size)
49
+
50
+ ckpt = os.path.join(checkpoints, f"{name}_best.pt")
51
+ _model.load_state_dict(torch.load(ckpt, map_location="cpu", weights_only=True))
52
+ _model.to(device)
53
+ _model.eval()
54
+
55
+ def prepare_text(profile):
56
+ parts = []
57
+ bio = str(profile.get("bio", "") or profile.get("description", "") or "")
58
+ if bio.strip():
59
+ parts.append(bio.strip())
60
+ for t in (profile.get("recent_tweets", []) or [])[:20]:
61
+ t = str(t).strip()
62
+ if t:
63
+ parts.append(t)
64
+ combined = " [SEP] ".join(parts)
65
+ combined = re.sub(r"http\S+", "<URL>", combined)
66
+ return re.sub(r"\s+", " ", combined).strip() or "<EMPTY>"
67
+
68
+ def extract_numeric(profile):
69
+ followers = float(profile.get("followers_count", 0))
70
+ friends = float(profile.get("following_count", 0) or profile.get("friends_count", 0))
71
+ statuses = float(profile.get("tweet_count", 0) or profile.get("statuses_count", 0))
72
+ favourites = float(profile.get("favourites_count", 0))
73
+ age = max(float(profile.get("account_age_days", 365)), 1.0)
74
+ tweets_per_day = statuses / age
75
+ bio = str(profile.get("bio", "") or profile.get("description", "") or "")
76
+ username = str(profile.get("username", "") or profile.get("screen_name", "") or "")
77
+ location = str(profile.get("location", "") or "")
78
+ verified = int(profile.get("is_verified", False) or profile.get("verified", False))
79
+ default_profile = int(profile.get("default_profile", False))
80
+ default_avatar = int(profile.get("has_default_avatar", False) or profile.get("default_profile_image", False))
81
+ f2f_ratio = followers / max(friends, 1)
82
+ fav2stat_ratio = favourites / max(statuses, 1)
83
+ fr2fol_ratio = friends / max(followers, 1)
84
+ stat2fol_ratio = statuses / max(followers, 1)
85
+ has_desc = int(len(bio) > 0)
86
+ has_loc = int(len(location) > 0)
87
+ completeness = has_desc + has_loc + (1 - default_profile) + (1 - default_avatar) + verified
88
+ sn_digits = sum(c.isdigit() for c in username)
89
+ sn_digit_ratio = sn_digits / max(len(username), 1)
90
+ sn_underscore = int("_" in username)
91
+ tweets_per_follower = statuses / max(followers, 1)
92
+ tpd_per_follower = tweets_per_day / max(followers, 1)
93
+ bio_urls = len(re.findall(r"http|www\.|\.com|\.net", bio))
94
+ bio_hashtags = bio.count("#")
95
+ bio_mentions = bio.count("@")
96
+ bio_words = len(bio.split()) if bio else 0
97
+ news_pattern = r"\b(?:news|breaking|daily|magazine|journal|times|herald|tribune|gazette|broadcast|media|press|reporter|journalist|editor|anchor|correspondent|coverage|headlines|report)\b"
98
+ org_pattern = r"\b(?:official|corp|inc\.?|llc|ltd|company|brand|store|shop|support|customer|service|team|foundation|organisation|organization|ngo|charity)\b"
99
+ bio_lower = bio.lower()
100
+ bio_has_news = int(bool(re.search(news_pattern, bio_lower)))
101
+ bio_has_org = int(bool(re.search(org_pattern, bio_lower)))
102
+ bio_likely_org = int((bio_has_news or bio_has_org) and followers > 1000 and age > 365)
103
+ is_established = int(bool(verified) and followers > 10000 and age > 365)
104
+ log_followers = float(np.log1p(followers))
105
+ log_friends = float(np.log1p(friends))
106
+ log_statuses = float(np.log1p(statuses))
107
+ log_favourites = float(np.log1p(favourites))
108
+ log_tpf = float(np.log1p(tweets_per_follower))
109
+ log_f2f = float(np.log1p(f2f_ratio))
110
+ return [
111
+ followers, friends, statuses, favourites, age, tweets_per_day,
112
+ log_followers, log_friends, log_statuses, log_favourites, log_tpf, log_f2f,
113
+ f2f_ratio, fav2stat_ratio, fr2fol_ratio, stat2fol_ratio,
114
+ verified, default_profile, default_avatar,
115
+ has_desc, has_loc, completeness, len(bio), len(username),
116
+ sn_digits, sn_digit_ratio, sn_underscore,
117
+ tweets_per_follower, tpd_per_follower,
118
+ bio_urls, bio_hashtags, bio_mentions, bio_words,
119
+ bio_has_news, bio_has_org, bio_likely_org, is_established,
120
+ ]
121
+
122
+ feature_descriptions = {
123
+ "followers_count": "total followers",
124
+ "friends_count": "total accounts followed",
125
+ "statuses_count": "total tweets posted",
126
+ "favourites_count": "total likes given",
127
+ "account_age_days": "how long the account has existed",
128
+ "average_tweets_per_day": "tweets posted per day on average",
129
+ "log_followers_count": "follower count (log scale)",
130
+ "log_friends_count": "following count (log scale)",
131
+ "log_statuses_count": "tweet count (log scale)",
132
+ "log_favourites_count": "likes given (log scale)",
133
+ "log_tweets_per_follower": "tweets per follower (log scale)",
134
+ "log_followers_to_friends_ratio": "follower-to-following balance (log scale)",
135
+ "followers_to_friends_ratio": "how many followers per account followed",
136
+ "favourites_to_statuses_ratio": "likes given per tweet posted",
137
+ "friends_to_followers_ratio": "how many followed per follower",
138
+ "statuses_to_followers_ratio": "tweets per follower",
139
+ "verified": "has the verified blue checkmark",
140
+ "default_profile": "still using the default profile theme",
141
+ "default_profile_image": "still using the default avatar",
142
+ "has_description": "has filled in a bio",
143
+ "has_location": "has filled in a location",
144
+ "profile_completeness": "how many profile fields are filled in",
145
+ "description_length": "length of the bio",
146
+ "screen_name_length": "length of the username",
147
+ "screen_name_digits": "number of digits in the username",
148
+ "screen_name_digit_ratio": "fraction of the username that is digits",
149
+ "screen_name_has_underscore": "username contains an underscore",
150
+ "tweets_per_follower": "tweets posted per follower",
151
+ "tweets_per_day_per_follower": "tweets per day relative to followers",
152
+ "bio_url_count": "URLs in the bio",
153
+ "bio_hashtag_count": "hashtags in the bio",
154
+ "bio_mention_count": "mentions in the bio",
155
+ "bio_word_count": "words in the bio",
156
+ "bio_has_news_keywords": "bio mentions news or journalism",
157
+ "bio_has_org_keywords": "bio mentions an organisation or brand",
158
+ "bio_likely_organisation": "bio plus reach suggests a real organisation",
159
+ "is_established_account": "verified, large following, account older than one year",
160
+ }
161
+
162
+ def format_feature_value(name, value):
163
+ if name == "verified":
164
+ return "yes" if value > 0.5 else "no"
165
+ if name in ("default_profile", "default_profile_image", "has_description", "has_location",
166
+ "screen_name_has_underscore", "bio_has_news_keywords", "bio_has_org_keywords",
167
+ "bio_likely_organisation", "is_established_account"):
168
+ return "yes" if value > 0.5 else "no"
169
+ if name == "account_age_days":
170
+ years = value / 365.0
171
+ if years >= 1:
172
+ return f"{years:.1f} yrs"
173
+ return f"{int(value)} days"
174
+ if name in ("followers_count", "friends_count", "statuses_count", "favourites_count"):
175
+ if value >= 1_000_000:
176
+ return f"{value/1_000_000:.1f}M"
177
+ if value >= 1_000:
178
+ return f"{value/1_000:.1f}K"
179
+ return str(int(value))
180
+ if name == "average_tweets_per_day":
181
+ return f"{value:.1f}/day"
182
+ if name == "profile_completeness":
183
+ return f"{int(value)}/5"
184
+ if name == "screen_name_length":
185
+ return f"{int(value)} chars"
186
+ if name.startswith("log_"):
187
+ return f"{value:.2f}"
188
+ if "ratio" in name:
189
+ return f"{value:.2f}"
190
+ if isinstance(value, float):
191
+ return f"{value:.1f}"
192
+ return str(value)
193
+
194
+ def compute_contributions(numeric_arr, raw_numeric):
195
+ if _model_info.get("model_type") != "xgboost":
196
+ return None
197
+ import xgboost as xgb
198
+ booster = _model.get_booster()
199
+ dmatrix = xgb.DMatrix(numeric_arr.reshape(1, -1))
200
+ contribs = booster.predict(dmatrix, pred_contribs=True)[0]
201
+ feat_contribs = contribs[:-1]
202
+ indexed = sorted(enumerate(feat_contribs), key=lambda x: abs(x[1]), reverse=True)
203
+ toward_bot, toward_human = [], []
204
+ for idx, contrib in indexed:
205
+ if abs(contrib) < 0.01:
206
+ continue
207
+ if len(toward_bot) >= 4 and len(toward_human) >= 4:
208
+ break
209
+ name = numeric_features[idx]
210
+ entry = {
211
+ "feature": name,
212
+ "description": feature_descriptions.get(name, name.replace("_", " ")),
213
+ "value": format_feature_value(name, float(raw_numeric[idx])),
214
+ "contribution": round(float(contrib), 3),
215
+ }
216
+ if contrib > 0 and len(toward_bot) < 4:
217
+ toward_bot.append(entry)
218
+ elif contrib < 0 and len(toward_human) < 4:
219
+ toward_human.append(entry)
220
+ return {"toward_bot": toward_bot, "toward_human": toward_human}
221
+
222
+ def generate_signals(profile, score):
223
+ signals = []
224
+ followers = int(profile.get("followers_count", 0))
225
+ following = int(profile.get("following_count", 0) or profile.get("friends_count", 0))
226
+ tweets = int(profile.get("tweet_count", 0) or profile.get("statuses_count", 0))
227
+ age = max(int(profile.get("account_age_days", 365)), 1)
228
+ if followers / max(following, 1) < 0.1 and following > 100:
229
+ signals.append("Very low follower-to-following ratio")
230
+ if age < 30:
231
+ signals.append("Account is less than 30 days old")
232
+ if tweets / age > 50:
233
+ signals.append("Extremely high tweet frequency")
234
+ if profile.get("has_default_avatar", False) or profile.get("default_profile_image", False):
235
+ signals.append("Using default profile image")
236
+ if followers < 5 and following > 500:
237
+ signals.append("Mass-following with few followers")
238
+ if len(str(profile.get("bio", "") or "")) < 5:
239
+ signals.append("Empty or very short bio")
240
+ if not signals and score >= 70:
241
+ signals.append("Text patterns indicate automated content")
242
+ if not signals:
243
+ signals.append("No strong bot signals detected")
244
+ return signals
245
+
246
+ def predict(profile):
247
+ load_model()
248
+
249
+ raw_numeric = extract_numeric(profile)
250
+ numeric_arr = np.array(raw_numeric, dtype=np.float32)
251
+ if _numeric_mean is not None and _numeric_std is not None:
252
+ numeric_arr = (numeric_arr - _numeric_mean) / _numeric_std
253
+
254
+ name = _model_info["model_name"]
255
+ model_type = _model_info.get("model_type", "neural")
256
+
257
+ if model_type == "xgboost":
258
+ bot_prob = float(_model.predict_proba(numeric_arr.reshape(1, -1))[0, 1])
259
+ else:
260
+ text = prepare_text(profile)
261
+ numeric = torch.tensor([numeric_arr], dtype=torch.float32, device=device)
262
+ with torch.no_grad():
263
+ tokens = _tokenizer.tokenize_batch([text], max_len=max_seq_len).to(device)
264
+ logits = _model(input_ids=tokens, numeric=numeric)
265
+ bot_prob = torch.sigmoid(logits.squeeze()).item()
266
+
267
+ raw_followers, raw_age = raw_numeric[0], raw_numeric[4]
268
+ raw_verified, raw_likely_org = raw_numeric[16], raw_numeric[35]
269
+ override_applied = None
270
+ if raw_likely_org and raw_verified and raw_age > 365 and raw_followers > 10_000:
271
+ capped = max(0.0, _threshold - 0.15)
272
+ if bot_prob > capped:
273
+ override_applied = "news_org"
274
+ bot_prob = min(bot_prob, capped)
275
+
276
+ score = int(round(bot_prob * 100))
277
+
278
+ margin = 0.18 if raw_age < 60 else 0.1
279
+ delta = bot_prob - _threshold
280
+ if abs(delta) <= margin:
281
+ label = "uncertain"
282
+ elif delta > 0:
283
+ label = "bot"
284
+ else:
285
+ label = "human"
286
+ return {
287
+ "username": profile.get("username", ""),
288
+ "bot_probability": round(bot_prob, 4),
289
+ "bot_score": score,
290
+ "label": label,
291
+ "confidence": "high" if abs(delta) > 0.3 else ("medium" if abs(delta) > 0.15 else "low"),
292
+ "signals": generate_signals(profile, score),
293
+ "contributions": compute_contributions(numeric_arr, raw_numeric),
294
+ "override_applied": override_applied,
295
+ "threshold": round(_threshold, 4),
296
+ "margin": round(margin, 4),
297
+ }
298
+
299
+ class PredictRequest(BaseModel):
300
+ username: str
301
+ display_name: str = ""
302
+ bio: str = ""
303
+ followers_count: int = 0
304
+ following_count: int = 0
305
+ tweet_count: int = 0
306
+ listed_count: int = 0
307
+ account_age_days: int = 365
308
+ recent_tweets: list[str] = []
309
+ has_default_avatar: bool = False
310
+ is_verified: bool = False
311
+ url: str = ""
312
+
313
+ class PredictResponse(BaseModel):
314
+ username: str
315
+ bot_probability: float
316
+ bot_score: int
317
+ label: str
318
+ confidence: str
319
+ signals: list[str]
320
+ contributions: dict | None = None
321
+ override_applied: str | None = None
322
+ threshold: float = 0.5
323
+ margin: float = 0.1
324
+
325
+ app = FastAPI(title="Twitter Bot Detector API", version="1.0.0")
326
+
327
+ app.add_middleware(
328
+ CORSMiddleware,
329
+ allow_origin_regex=r"^(https://(x|twitter)\.com|chrome-extension://.*)$",
330
+ allow_credentials=False,
331
+ allow_methods=["POST", "GET"],
332
+ allow_headers=["Content-Type"],
333
+ )
334
+
335
+ @app.on_event("startup")
336
+ async def startup():
337
+ try:
338
+ load_model()
339
+ print("[+] Model loaded")
340
+ except FileNotFoundError:
341
+ print("[!] No model found, train first with: python src/train.py")
342
+ except Exception as e:
343
+ print(f"[!] Model load failed: {e}")
344
+
345
+
346
+ @app.post("/predict", response_model=PredictResponse)
347
+ async def predict_endpoint(request: PredictRequest):
348
+ try:
349
+ return PredictResponse(**predict(request.model_dump()))
350
+ except FileNotFoundError as e:
351
+ raise HTTPException(status_code=503, detail=str(e))
352
+ except Exception as e:
353
+ raise HTTPException(status_code=500, detail=str(e))
354
+
355
+ class BatchRequest(BaseModel):
356
+ profiles: list[PredictRequest]
357
+
358
+ class BatchResponse(BaseModel):
359
+ results: list[PredictResponse]
360
+
361
+ @app.post("/predict_batch", response_model=BatchResponse)
362
+ async def predict_batch_endpoint(request: BatchRequest):
363
+ if len(request.profiles) > 50:
364
+ raise HTTPException(status_code=429, detail="batch limit is 50 profiles")
365
+ try:
366
+ results = [PredictResponse(**predict(p.model_dump())) for p in request.profiles]
367
+ return BatchResponse(results=results)
368
+ except FileNotFoundError as e:
369
+ raise HTTPException(status_code=503, detail=str(e))
370
+ except Exception as e:
371
+ raise HTTPException(status_code=500, detail=str(e))
372
+
373
+ @app.get("/health")
374
+ async def health():
375
+ return {
376
+ "status": "ok",
377
+ "model_loaded": _model is not None,
378
+ "model_name": _model_info.get("model_name", "") if _model_info else "",
379
+ }
models/checkpoints/best_model_info.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "accuracy": 0.8725,
3
+ "precision": 0.8336,
4
+ "recall": 0.7832,
5
+ "f1": 0.8076,
6
+ "threshold": 0.58,
7
+ "roc_auc": 0.9329,
8
+ "pr_auc": 0.9031,
9
+ "confusion_matrix": [
10
+ [
11
+ 3397,
12
+ 300
13
+ ],
14
+ [
15
+ 416,
16
+ 1503
17
+ ]
18
+ ],
19
+ "model_name": "xgboost",
20
+ "model_type": "xgboost",
21
+ "train_time_s": 0.7579660415649414,
22
+ "epochs_trained": 177,
23
+ "num_params": 1000
24
+ }
models/checkpoints/xgboost_best.json ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ datasets
2
+ fastapi
3
+ ijson
4
+ jupyter
5
+ matplotlib
6
+ numpy
7
+ pandas
8
+ pydantic
9
+ scikit-learn
10
+ seaborn
11
+ sentence-transformers
12
+ <<<<<<< Updated upstream
13
+ torch
14
+ =======
15
+ torch>=2.0
16
+ >>>>>>> Stashed changes
17
+ tqdm
18
+ uvicorn[standard]
19
+ xgboost
src/config.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+
4
+ root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
5
+ data_raw = os.path.join(root_dir, "data", "raw")
6
+ data_processed = os.path.join(root_dir, "data", "processed")
7
+ checkpoints = os.path.join(root_dir, "models", "checkpoints")
8
+
9
+ dataset_csv = os.path.join(data_raw, "twitter_human_bots.csv")
10
+
11
+ glove_dir = os.path.join(data_raw, "glove")
12
+ glove_file = os.path.join(glove_dir, "glove.twitter.27B.200d.txt")
13
+
14
+ def get_device():
15
+ if torch.cuda.is_available():
16
+ return torch.device("cuda")
17
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
18
+ return torch.device("mps")
19
+ return torch.device("cpu")
20
+
21
+ device = get_device()
22
+
23
+ numeric_features = [
24
+ "followers_count", "friends_count", "statuses_count", "favourites_count",
25
+ "account_age_days", "average_tweets_per_day",
26
+ "log_followers_count", "log_friends_count", "log_statuses_count",
27
+ "log_favourites_count", "log_tweets_per_follower", "log_followers_to_friends_ratio",
28
+ "followers_to_friends_ratio", "favourites_to_statuses_ratio",
29
+ "friends_to_followers_ratio", "statuses_to_followers_ratio",
30
+ "verified", "default_profile", "default_profile_image",
31
+ "has_description", "has_location", "profile_completeness",
32
+ "description_length", "screen_name_length",
33
+ "screen_name_digits", "screen_name_digit_ratio", "screen_name_has_underscore",
34
+ "tweets_per_follower", "tweets_per_day_per_follower",
35
+ "bio_url_count", "bio_hashtag_count", "bio_mention_count", "bio_word_count",
36
+ "bio_has_news_keywords", "bio_has_org_keywords", "bio_likely_organisation",
37
+ "is_established_account",
38
+ ]
39
+ num_numeric_features = len(numeric_features)
40
+
41
+ batch_size = 64
42
+ max_epochs = 50
43
+ patience = 7
44
+ learning_rate = 1e-3
45
+ weight_decay = 1e-5
46
+ dropout = 0.3
47
+
48
+ max_seq_len = 128
49
+ max_vocab_size = 50_000
50
+ glove_dim = 200
51
+
52
+ cnn_filters = [3, 4, 5]
53
+ cnn_num_filters = 100
54
+
55
+ rnn_hidden = 128
56
+ rnn_layers = 1
57
+
58
+ label_map = {"human": 0, "bot": 1}
59
+ inv_label_map = {0: "human", 1: "bot"}
src/data.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import re
4
+ import sys
5
+ from collections import Counter
6
+ import numpy as np
7
+ import pandas as pd
8
+ import torch
9
+ from torch.utils.data import Dataset
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+ from src.config import (
12
+ dataset_csv, numeric_features, data_processed, data_raw, label_map,
13
+ max_seq_len, max_vocab_size, glove_dim, glove_file,
14
+ )
15
+
16
+ def download_dataset():
17
+ if os.path.exists(dataset_csv):
18
+ return
19
+
20
+ print("Dataset not found, downloading from Hugging Face")
21
+ from datasets import load_dataset
22
+
23
+ os.makedirs(data_raw, exist_ok=True)
24
+ ds = load_dataset("airt-ml/twitter-human-bots", split="train")
25
+ ds.to_csv(dataset_csv)
26
+ print(f"Downloaded {len(ds):,} rows to {dataset_csv}")
27
+
28
+
29
+ def load_and_preprocess():
30
+ print("Loading dataset")
31
+ download_dataset()
32
+
33
+ if not os.path.exists(dataset_csv):
34
+ raise FileNotFoundError("Dataset not found and download failed.")
35
+
36
+ df = pd.read_csv(dataset_csv)
37
+ print(f"Rows: {len(df):,}")
38
+
39
+ df["label"] = df["account_type"].map(label_map)
40
+ df = df.dropna(subset=["label"])
41
+ df["label"] = df["label"].astype(int)
42
+
43
+ bot_count = df["label"].sum()
44
+ print(f"Bots: {bot_count:,}, humans: {len(df) - bot_count:,}")
45
+
46
+ df["followers_count"] = df["followers_count"].fillna(0).astype(float)
47
+ df["friends_count"] = df["friends_count"].fillna(0).astype(float)
48
+ df["statuses_count"] = df["statuses_count"].fillna(0).astype(float)
49
+ df["favourites_count"] = df["favourites_count"].fillna(0).astype(float)
50
+ df["account_age_days"] = df["account_age_days"].fillna(365).astype(float).clip(lower=1)
51
+ df["average_tweets_per_day"] = df["average_tweets_per_day"].fillna(0).astype(float)
52
+ df["verified"] = df["verified"].fillna(False).astype(int)
53
+ df["default_profile"] = df["default_profile"].fillna(False).astype(int)
54
+ df["default_profile_image"] = df["default_profile_image"].fillna(False).astype(int)
55
+ df["description"] = df["description"].fillna("")
56
+ df["screen_name"] = df["screen_name"].fillna("")
57
+ df["location"] = df["location"].fillna("")
58
+
59
+ df["followers_to_friends_ratio"] = df["followers_count"] / df["friends_count"].clip(lower=1)
60
+ df["favourites_to_statuses_ratio"] = df["favourites_count"] / df["statuses_count"].clip(lower=1)
61
+ df["friends_to_followers_ratio"] = df["friends_count"] / df["followers_count"].clip(lower=1)
62
+ df["statuses_to_followers_ratio"] = df["statuses_count"] / df["followers_count"].clip(lower=1)
63
+
64
+ df["has_description"] = (df["description"].str.len() > 0).astype(int)
65
+ df["has_location"] = (df["location"].str.len() > 0).astype(int)
66
+ df["description_length"] = df["description"].str.len()
67
+ df["screen_name_length"] = df["screen_name"].str.len()
68
+ df["profile_completeness"] = (
69
+ df["has_description"] + df["has_location"]
70
+ + (1 - df["default_profile"]) + (1 - df["default_profile_image"])
71
+ + df["verified"]
72
+ )
73
+
74
+ df["screen_name_digits"] = df["screen_name"].apply(lambda x: sum(c.isdigit() for c in str(x)))
75
+ df["screen_name_digit_ratio"] = df["screen_name_digits"] / df["screen_name_length"].clip(lower=1)
76
+ df["screen_name_has_underscore"] = df["screen_name"].str.contains("_", na=False).astype(int)
77
+
78
+ df["tweets_per_follower"] = df["statuses_count"] / df["followers_count"].clip(lower=1)
79
+ df["tweets_per_day_per_follower"] = df["average_tweets_per_day"] / df["followers_count"].clip(lower=1)
80
+
81
+ df["bio_url_count"] = df["description"].str.count(r"http|www\.|\.com|\.net")
82
+ df["bio_hashtag_count"] = df["description"].str.count(r"#")
83
+ df["bio_mention_count"] = df["description"].str.count(r"@")
84
+ df["bio_word_count"] = df["description"].str.split().str.len().fillna(0).astype(int)
85
+
86
+ news_pattern = r"\b(?:news|breaking|daily|magazine|journal|times|herald|tribune|gazette|broadcast|media|press|reporter|journalist|editor|anchor|correspondent|coverage|headlines|report)\b"
87
+ org_pattern = r"\b(?:official|corp|inc\.?|llc|ltd|company|brand|store|shop|support|customer|service|team|foundation|organisation|organization|ngo|charity)\b"
88
+ df["bio_has_news_keywords"] = df["description"].str.lower().str.contains(news_pattern, regex=True, na=False).astype(int)
89
+ df["bio_has_org_keywords"] = df["description"].str.lower().str.contains(org_pattern, regex=True, na=False).astype(int)
90
+ df["bio_likely_organisation"] = (
91
+ (df["bio_has_news_keywords"] | df["bio_has_org_keywords"])
92
+ & (df["followers_count"] > 1000)
93
+ & (df["account_age_days"] > 365)
94
+ ).astype(int)
95
+ df["is_established_account"] = (
96
+ (df["verified"] == 1)
97
+ & (df["followers_count"] > 10000)
98
+ & (df["account_age_days"] > 365)
99
+ ).astype(int)
100
+
101
+ df["log_followers_count"] = np.log1p(df["followers_count"])
102
+ df["log_friends_count"] = np.log1p(df["friends_count"])
103
+ df["log_statuses_count"] = np.log1p(df["statuses_count"])
104
+ df["log_favourites_count"] = np.log1p(df["favourites_count"])
105
+ df["log_tweets_per_follower"] = np.log1p(df["tweets_per_follower"])
106
+ df["log_followers_to_friends_ratio"] = np.log1p(df["followers_to_friends_ratio"])
107
+
108
+ print(f"Engineered {len(numeric_features)} features")
109
+
110
+ texts = []
111
+ for _, row in df.iterrows():
112
+ desc = str(row.get("description", "") or "")
113
+ desc = re.sub(r"http\S+", "<URL>", desc)
114
+ desc = re.sub(r"\s+", " ", desc).strip()
115
+ texts.append(desc if desc else "<EMPTY>")
116
+
117
+ n = len(df)
118
+ indices = np.random.RandomState(42).permutation(n)
119
+ train_end = int(0.7 * n)
120
+ val_end = int(0.85 * n)
121
+
122
+ user_ids = [str(i) for i in range(n)]
123
+ splits = {
124
+ "train": [user_ids[i] for i in indices[:train_end]],
125
+ "val": [user_ids[i] for i in indices[train_end:val_end]],
126
+ "test": [user_ids[i] for i in indices[val_end:]],
127
+ }
128
+
129
+ print(f"Split: {len(splits['train']):,} train, {len(splits['val']):,} val, {len(splits['test']):,} test")
130
+ print(f"Preprocessed {n:,} users")
131
+
132
+ return df, texts, df["label"].values, splits, user_ids
133
+
134
+ def save_processed(df, texts, labels, splits, user_ids):
135
+ os.makedirs(data_processed, exist_ok=True)
136
+
137
+ numeric_values = df[numeric_features].values.astype(np.float32)
138
+
139
+ train_indices = [int(uid) for uid in splits["train"]]
140
+ train_numeric = numeric_values[train_indices]
141
+ mean = train_numeric.mean(axis=0)
142
+ std = train_numeric.std(axis=0)
143
+ std[std == 0] = 1.0
144
+
145
+ numeric_normalised = (numeric_values - mean) / std
146
+ print(f"Normalised {len(numeric_features)} features (fitted on train split)")
147
+
148
+ torch.save({
149
+ "texts": texts,
150
+ "labels": torch.tensor(labels, dtype=torch.long),
151
+ "user_ids": user_ids,
152
+ "splits": splits,
153
+ "numeric_features": torch.tensor(numeric_normalised, dtype=torch.float32),
154
+ "numeric_mean": mean,
155
+ "numeric_std": std,
156
+ }, os.path.join(data_processed, "processed.pt"))
157
+
158
+ print(f"Saved to {data_processed}/")
159
+
160
+ def load_processed():
161
+ path = os.path.join(data_processed, "processed.pt")
162
+ if not os.path.exists(path):
163
+ raise FileNotFoundError(f"Run: python src/data.py (no data at {path})")
164
+ return torch.load(path, weights_only=False)
165
+
166
+ class TwiBotDataset(Dataset):
167
+ def __init__(self, texts, numeric_features, labels, user_ids, split_ids=None):
168
+ if split_ids is not None:
169
+ id_set = set(split_ids)
170
+ indices = [i for i, uid in enumerate(user_ids) if uid in id_set]
171
+ else:
172
+ indices = list(range(len(user_ids)))
173
+
174
+ self.texts = [texts[i] for i in indices]
175
+ self.numeric = numeric_features[indices]
176
+ self.labels = labels[indices]
177
+
178
+ def __len__(self):
179
+ return len(self.labels)
180
+
181
+ def __getitem__(self, idx):
182
+ return {"text": self.texts[idx], "numeric": self.numeric[idx], "label": self.labels[idx]}
183
+
184
+ def create_datasets(data, splits):
185
+ texts = data["texts"]
186
+ numeric = data["numeric_features"]
187
+ labels = data["labels"]
188
+ user_ids = data["user_ids"]
189
+ train = TwiBotDataset(texts, numeric, labels, user_ids, splits.get("train"))
190
+ val = TwiBotDataset(texts, numeric, labels, user_ids, splits.get("val", splits.get("valid")))
191
+ test = TwiBotDataset(texts, numeric, labels, user_ids, splits.get("test"))
192
+ print(f"Datasets: train={len(train)}, val={len(val)}, test={len(test)}")
193
+ return train, val, test
194
+
195
+ def _tokenize_text(text):
196
+ text = text.lower()
197
+ text = re.sub(r"<URL>", " url ", text)
198
+ text = re.sub(r"[^\w\s]", " ", text)
199
+ return text.split()
200
+
201
+ class GloveVocab:
202
+ def __init__(self, word2idx=None):
203
+ self.word2idx = word2idx or {"<PAD>": 0, "<UNK>": 1}
204
+
205
+ @property
206
+ def vocab_size(self):
207
+ return len(self.word2idx)
208
+
209
+ @classmethod
210
+ def build_from_corpus(cls, texts, max_vocab=max_vocab_size):
211
+ counter = Counter()
212
+ for text in texts:
213
+ counter.update(_tokenize_text(text))
214
+ word2idx = {"<PAD>": 0, "<UNK>": 1}
215
+ for word, _ in counter.most_common(max_vocab - 2):
216
+ word2idx[word] = len(word2idx)
217
+ vocab = cls(word2idx)
218
+ print(f"Vocabulary: {vocab.vocab_size:,} words")
219
+ return vocab
220
+
221
+ def tokenize_batch(self, texts, max_len=max_seq_len):
222
+ batch = []
223
+ for text in texts:
224
+ tokens = _tokenize_text(text)
225
+ ids = [self.word2idx.get(t, 1) for t in tokens[:max_len]]
226
+ ids += [0] * (max_len - len(ids))
227
+ batch.append(ids)
228
+ return torch.tensor(batch, dtype=torch.long)
229
+
230
+ def load_glove_embeddings(self, path=glove_file):
231
+ print(f"Loading GloVe from {os.path.basename(path)}")
232
+ glove = {}
233
+ with open(path, "r", encoding="utf-8") as f:
234
+ for line in f:
235
+ parts = line.rstrip().split(" ")
236
+ if parts[0] in self.word2idx:
237
+ glove[parts[0]] = np.array(parts[1:], dtype=np.float32)
238
+
239
+ matrix = np.random.normal(scale=0.6, size=(self.vocab_size, glove_dim)).astype(np.float32)
240
+ matrix[0] = 0.0
241
+ found = sum(1 for w in self.word2idx if w in glove)
242
+ print(f"GloVe coverage: {found:,}/{self.vocab_size:,} ({found/self.vocab_size*100:.1f}%)")
243
+ for w, i in self.word2idx.items():
244
+ if w in glove:
245
+ matrix[i] = glove[w]
246
+ return matrix
247
+
248
+ def random_embeddings(self):
249
+ matrix = np.random.normal(scale=0.6, size=(self.vocab_size, glove_dim)).astype(np.float32)
250
+ matrix[0] = 0.0
251
+ print(f"Using random embeddings ({self.vocab_size:,} x {glove_dim})")
252
+ return matrix
253
+
254
+ def save(self, path):
255
+ with open(path, "w") as f:
256
+ json.dump(self.word2idx, f)
257
+
258
+ @classmethod
259
+ def load(cls, path):
260
+ with open(path) as f:
261
+ return cls(json.load(f))
262
+
263
+ if __name__ == "__main__":
264
+ df, texts, labels, splits, user_ids = load_and_preprocess()
265
+ save_processed(df, texts, labels, splits, user_ids)
src/models.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.nn.functional as F
4
+
5
+ from src.config import (
6
+ glove_dim, rnn_hidden, rnn_layers, dropout,
7
+ num_numeric_features, max_vocab_size,
8
+ cnn_filters, cnn_num_filters,
9
+ )
10
+
11
+ class NumericNet(nn.Module):
12
+ def __init__(self, num_features=num_numeric_features, hidden=128, dropout=dropout):
13
+ super().__init__()
14
+ self.net = nn.Sequential(
15
+ nn.Linear(num_features, hidden),
16
+ nn.ReLU(),
17
+ nn.BatchNorm1d(hidden),
18
+ nn.Dropout(dropout),
19
+ nn.Linear(hidden, hidden),
20
+ nn.ReLU(),
21
+ nn.Dropout(dropout),
22
+ )
23
+
24
+ def forward(self, x):
25
+ return self.net(x)
26
+
27
+
28
+ class BiGRU_LSTM(nn.Module):
29
+ def __init__(self, vocab_size=max_vocab_size, embed_dim=glove_dim,
30
+ rnn_hidden=rnn_hidden, rnn_layers=rnn_layers,
31
+ num_numeric=num_numeric_features, dropout=dropout,
32
+ pretrained_embeddings=None):
33
+ super().__init__()
34
+ self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
35
+ if pretrained_embeddings is not None:
36
+ self.embedding.weight = nn.Parameter(
37
+ torch.tensor(pretrained_embeddings, dtype=torch.float32), requires_grad=False)
38
+
39
+ self.bigru = nn.GRU(embed_dim, rnn_hidden, num_layers=rnn_layers,
40
+ batch_first=True, bidirectional=True,
41
+ dropout=dropout if rnn_layers > 1 else 0)
42
+ self.lstm = nn.LSTM(rnn_hidden * 2, rnn_hidden, num_layers=rnn_layers,
43
+ batch_first=True, dropout=dropout if rnn_layers > 1 else 0)
44
+
45
+ self.numeric_net = NumericNet(num_numeric, hidden=128, dropout=dropout)
46
+
47
+ fused_dim = 128
48
+ self.text_proj = nn.Linear(rnn_hidden, fused_dim)
49
+ self.gate = nn.Linear(fused_dim * 2, fused_dim)
50
+
51
+ self.dropout = nn.Dropout(dropout)
52
+ self.classifier = nn.Linear(fused_dim, 1)
53
+
54
+ def forward(self, input_ids, numeric):
55
+ embedded = self.embedding(input_ids)
56
+ gru_out, _ = self.bigru(embedded)
57
+ _, (h_n, _) = self.lstm(gru_out)
58
+ text_repr = self.text_proj(h_n.squeeze(0))
59
+ num_repr = self.numeric_net(numeric)
60
+
61
+ gate = torch.sigmoid(self.gate(torch.cat([text_repr, num_repr], dim=1)))
62
+ fused = gate * text_repr + (1 - gate) * num_repr
63
+
64
+ x = self.dropout(F.relu(fused))
65
+ return self.classifier(x)
66
+
67
+
68
+ class CNN_BiLSTM(nn.Module):
69
+ def __init__(self, vocab_size=max_vocab_size, embed_dim=glove_dim,
70
+ filter_sizes=cnn_filters, num_filters=cnn_num_filters,
71
+ rnn_hidden=rnn_hidden, rnn_layers=rnn_layers,
72
+ num_numeric=num_numeric_features, dropout=dropout,
73
+ pretrained_embeddings=None):
74
+ super().__init__()
75
+ self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
76
+ if pretrained_embeddings is not None:
77
+ self.embedding.weight = nn.Parameter(
78
+ torch.tensor(pretrained_embeddings, dtype=torch.float32), requires_grad=False)
79
+
80
+ self.convs = nn.ModuleList([nn.Conv1d(embed_dim, num_filters, fs) for fs in filter_sizes])
81
+ cnn_out = num_filters * len(filter_sizes)
82
+
83
+ self.bilstm = nn.LSTM(cnn_out, rnn_hidden, num_layers=rnn_layers,
84
+ batch_first=True, bidirectional=True,
85
+ dropout=dropout if rnn_layers > 1 else 0)
86
+
87
+ self.numeric_net = NumericNet(num_numeric, hidden=128, dropout=dropout)
88
+
89
+ fused_dim = 128
90
+ self.text_proj = nn.Linear(rnn_hidden * 2, fused_dim)
91
+ self.gate = nn.Linear(fused_dim * 2, fused_dim)
92
+
93
+ self.dropout = nn.Dropout(dropout)
94
+ self.classifier = nn.Linear(fused_dim, 1)
95
+
96
+ def forward(self, input_ids, numeric):
97
+ embedded = self.embedding(input_ids).permute(0, 2, 1)
98
+ conv_outs = [F.max_pool1d(F.relu(conv(embedded)), conv(embedded).size(2)).squeeze(2)
99
+ for conv in self.convs]
100
+ cnn_out = torch.cat(conv_outs, dim=1).unsqueeze(1)
101
+
102
+ _, (h_n, _) = self.bilstm(cnn_out)
103
+ text_repr = self.text_proj(torch.cat([h_n[-2], h_n[-1]], dim=1))
104
+ num_repr = self.numeric_net(numeric)
105
+
106
+ gate = torch.sigmoid(self.gate(torch.cat([text_repr, num_repr], dim=1)))
107
+ fused = gate * text_repr + (1 - gate) * num_repr
108
+
109
+ x = self.dropout(F.relu(fused))
110
+ return self.classifier(x)