Spaces:
Running
Running
Commit ·
08b3b18
1
Parent(s): 77c46ac
initial deployment
Browse files- .dockerignore +8 -0
- .gitignore +6 -0
- Dockerfile +12 -0
- api/app.py +379 -0
- models/checkpoints/best_model_info.json +24 -0
- models/checkpoints/xgboost_best.json +0 -0
- requirements.txt +19 -0
- src/config.py +59 -0
- src/data.py +265 -0
- src/models.py +110 -0
.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)
|