initial commit
This commit is contained in:
commit
61e345a961
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
env
|
||||||
|
env/**
|
||||||
|
.idea
|
||||||
|
.idea/**
|
3
.idea/.gitignore
vendored
Normal file
3
.idea/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
105
README.md
Normal file
105
README.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# AntiSpam complex
|
||||||
|
|
||||||
|
Комплекс ПО для борьбы со спамом
|
||||||
|
|
||||||
|
## Компоненты
|
||||||
|
|
||||||
|
### Диаграмма компонентов
|
||||||
|
|
||||||
|
![c4model.png](docs/c4model.png)
|
||||||
|
|
||||||
|
### Dataset
|
||||||
|
|
||||||
|
- необходим первоначальный датасет (ham/spam) в формате CSV
|
||||||
|
|
||||||
|
### Model
|
||||||
|
|
||||||
|
- использует датасет
|
||||||
|
- модели генерируются https://pypi.org/project/spam-detector-ai/
|
||||||
|
- naive_bayes_model.joblib
|
||||||
|
- random_forest_model.joblib
|
||||||
|
- svm_model.joblib
|
||||||
|
- logistic_regression_model.joblib
|
||||||
|
- xgb_model.joblib
|
||||||
|
|
||||||
|
### Decision Maker
|
||||||
|
|
||||||
|
- компонент, который принимает решения
|
||||||
|
- представлен в виде Web-сервера
|
||||||
|
- реализует API
|
||||||
|
- POST `/check-spam` - открытый для пользователей
|
||||||
|
- auth: none
|
||||||
|
- request
|
||||||
|
- body `{ "text": "SOME TEXT" }
|
||||||
|
- response
|
||||||
|
- status code 200
|
||||||
|
- body `{ "is_spam": true }`
|
||||||
|
- POST `/update-model` - служебный, добавляет текст в датасет
|
||||||
|
- auth: TOKEN
|
||||||
|
- request
|
||||||
|
- body: `{ "text": "SOME TEXT", "is_spam": true }`
|
||||||
|
- response
|
||||||
|
- status code 200
|
||||||
|
- body `{ "status": "OK" }`
|
||||||
|
- POST `/restart` - служебный, перезапускает данный компонент
|
||||||
|
- auth: TOKEN
|
||||||
|
- request
|
||||||
|
- body: none
|
||||||
|
- response
|
||||||
|
- status code 200
|
||||||
|
- body `{ "status": "OK" }`
|
||||||
|
|
||||||
|
#### Sequence diagram
|
||||||
|
|
||||||
|
![sequence](docs/sequence.png)
|
||||||
|
|
||||||
|
### Model Updater
|
||||||
|
|
||||||
|
- добавляет в датасет новые тексты из базы дообучения
|
||||||
|
- запускается по графику, выполняет
|
||||||
|
- сделать бэкап модели
|
||||||
|
- запустить дообучение
|
||||||
|
- при успешном результате дообучения
|
||||||
|
- вызывает POST `/restart` компонента Decision Maker
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
- используется [Rabbit MQ](https://rabbitmq.com/)
|
||||||
|
|
||||||
|
## Use cases
|
||||||
|
|
||||||
|
### Телеграм-бот
|
||||||
|
|
||||||
|
- приходит сообщение в группу в телегу
|
||||||
|
- бот его читает (не входит в данный комплекс)
|
||||||
|
- отправляет на проверку
|
||||||
|
- получает результат: спам/не спам
|
||||||
|
- если спам - бот удаляет сообщение
|
||||||
|
|
||||||
|
### На почте
|
||||||
|
|
||||||
|
- та же схема, только должен быть работающий клиент почты, который читает каждое сообщение, и если обнаружился спам, то помечать его спамом (помещать в категорию спам)
|
||||||
|
|
||||||
|
## Дообучение с учителем
|
||||||
|
|
||||||
|
### Сбор доп.текстов через Телеграм
|
||||||
|
|
||||||
|
- владелец бота в телеге встречает сообщение, которое является спамом, но не было удалено
|
||||||
|
- это сообщение отправляется в ТГ бот спам-определителя (не реализовано)
|
||||||
|
- сообщение помещается в базу для дообучения
|
||||||
|
|
||||||
|
### Сбор доп.текстов через почту
|
||||||
|
|
||||||
|
- спам-письмо можно отправить на почту, которую читает бот (не реализовано)
|
||||||
|
- бот кладет текст письма в базу дообучения
|
||||||
|
|
||||||
|
### Дообучение
|
||||||
|
|
||||||
|
- 1 р/сут происходит вычитывание всех сообщений из базы дообучения за день
|
||||||
|
- если в базе дообучения за день что-то есть, то
|
||||||
|
- датасет обновляется
|
||||||
|
- запускается процесс дообучения
|
||||||
|
- перезапускается модель
|
||||||
|
|
||||||
|
|
||||||
|
|
BIN
docs/c4model.png
Normal file
BIN
docs/c4model.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.4 KiB |
BIN
docs/sequence.png
Normal file
BIN
docs/sequence.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 27 KiB |
26
pyproject.toml
Normal file
26
pyproject.toml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
[tool.poetry]
|
||||||
|
name = "spam-detector-1"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = ""
|
||||||
|
authors = ["bvn13 <from.github@bvn13.me>"]
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.12"
|
||||||
|
scikit-learn = "^1.5.2"
|
||||||
|
numpy = "^2.1.2"
|
||||||
|
spam-detector-ai = "^2.1.18"
|
||||||
|
nltk = "^3.9.1"
|
||||||
|
joblib = "^1.4.2"
|
||||||
|
xgboost = "^2.1.2"
|
||||||
|
imblearn = "^0.0"
|
||||||
|
requests = "^2.32.3"
|
||||||
|
googletrans = "^4.0.0rc1"
|
||||||
|
tqdm = "^4.66.5"
|
||||||
|
tornado = "^6.4.1"
|
||||||
|
asyncio = "^3.4.3"
|
||||||
|
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
27
src/app.py
Normal file
27
src/app.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from dataset.preparer import download_words
|
||||||
|
from model.trainer import train
|
||||||
|
import web.server
|
||||||
|
import model.updater
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(prog='Antispam complex')
|
||||||
|
parser.add_argument('-i', '--init', action=argparse.BooleanOptionalAction, help='Initializing, must be run beforehand, --dataset is required')
|
||||||
|
parser.add_argument('-m', '--decision-maker', action=argparse.BooleanOptionalAction, help='Start as Decision maker')
|
||||||
|
parser.add_argument('-d', '--dataset', required=False, help='Path to CSV (ham/spam) dataset')
|
||||||
|
parser.add_argument('-u', '--model-updater', required=False, help='Start as Model updater')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
_port = 8080 if os.getenv('PORT') is None else os.getenv('PORT')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if args.init:
|
||||||
|
assert args.dataset is not None, "Dataset is required, show --help"
|
||||||
|
download_words()
|
||||||
|
train(args.dataset)
|
||||||
|
elif args.decision_maker:
|
||||||
|
web.server.start(port=_port)
|
||||||
|
elif args.model_updater:
|
||||||
|
model.updater.start()
|
0
src/dataset/__init__.py
Normal file
0
src/dataset/__init__.py
Normal file
5
src/dataset/preparer.py
Normal file
5
src/dataset/preparer.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import nltk
|
||||||
|
|
||||||
|
def download_words() -> None:
|
||||||
|
nltk.download('wordnet')
|
||||||
|
nltk.download('stopwords')
|
0
src/logging/__init__.py
Normal file
0
src/logging/__init__.py
Normal file
5
src/logging/logger.py
Normal file
5
src/logging/logger.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(format="%(asctime)s | %(name)s | %(levelname)s | %(message)s")
|
||||||
|
logger = logging.getLogger(__package__)
|
||||||
|
logger.setLevel(logging.INFO)
|
0
src/model/__init__.py
Normal file
0
src/model/__init__.py
Normal file
40
src/model/trainer.py
Normal file
40
src/model/trainer.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# spam_detector_ai/trainer.py
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from sklearn.model_selection import train_test_split
|
||||||
|
from spam_detector_ai.classifiers.classifier_types import ClassifierType
|
||||||
|
from spam_detector_ai.training.train_models import ModelTrainer
|
||||||
|
from src.logging.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
def _train_model(classifier_type, model_filename, vectoriser_filename, X_train, y_train):
|
||||||
|
logger.info(f'Training {classifier_type}')
|
||||||
|
trainer_ = ModelTrainer(data=None, classifier_type=classifier_type, logger=logger)
|
||||||
|
trainer_.train(X_train, y_train)
|
||||||
|
trainer_.save_model(model_filename, vectoriser_filename)
|
||||||
|
|
||||||
|
|
||||||
|
def train(data_path: str) -> None:
|
||||||
|
# Load and preprocess data once
|
||||||
|
# data_path = os.path.join(project_root, 'spam.csv')
|
||||||
|
initial_trainer = ModelTrainer(data_path=data_path, logger=logger)
|
||||||
|
processed_data = initial_trainer.preprocess_data_()
|
||||||
|
|
||||||
|
# Split the data once
|
||||||
|
X__train, _, y__train, _ = train_test_split(processed_data['processed_text'], processed_data['label'],
|
||||||
|
test_size=0.2, random_state=0)
|
||||||
|
|
||||||
|
# Configurations for each model
|
||||||
|
configurations = [
|
||||||
|
(ClassifierType.SVM, 'svm_model.joblib', 'svm_vectoriser.joblib'),
|
||||||
|
(ClassifierType.NAIVE_BAYES, 'naive_bayes_model.joblib', 'naive_bayes_vectoriser.joblib'),
|
||||||
|
(ClassifierType.RANDOM_FOREST, 'random_forest_model.joblib', 'random_forest_vectoriser.joblib'),
|
||||||
|
(ClassifierType.XGB, 'xgb_model.joblib', 'xgb_vectoriser.joblib'),
|
||||||
|
(ClassifierType.LOGISTIC_REGRESSION, 'logistic_regression_model.joblib', 'logistic_regression_vectoriser.joblib')
|
||||||
|
]
|
||||||
|
|
||||||
|
# Train each model with the pre-split data
|
||||||
|
logger.info(f"Train each model with the pre-split data\n")
|
||||||
|
for ct, mf, vf in configurations:
|
||||||
|
_train_model(ct, mf, vf, X__train, y__train)
|
5
src/model/updater.py
Normal file
5
src/model/updater.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from src.logging.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
def start() -> None:
|
||||||
|
logger.info("Starting...")
|
75
src/translate-dataset.py
Normal file
75
src/translate-dataset.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# https://thepythoncode.com/article/translate-text-in-python
|
||||||
|
from os import close
|
||||||
|
|
||||||
|
from googletrans import Translator
|
||||||
|
import csv
|
||||||
|
from tqdm import tqdm
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import os.path
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
csv.field_size_limit(sys.maxsize)
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(prog='translate-dataset')
|
||||||
|
parser.add_argument('-i', '--input', help='Source file')
|
||||||
|
parser.add_argument('-o', '--output', help='Destination file')
|
||||||
|
args = parser.parse_args()
|
||||||
|
#/home/bvn13/develop/spam-detector-1/spam.csv
|
||||||
|
|
||||||
|
translator = Translator()
|
||||||
|
|
||||||
|
translation = translator.translate("Hola Mundo", dest="ru")
|
||||||
|
print(f"{translation.origin} ({translation.src}) --> {translation.text} ({translation.dest})")
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
with open(args.input, "r") as f:
|
||||||
|
reader = csv.reader(f)
|
||||||
|
for row in reader:
|
||||||
|
total += 1
|
||||||
|
skip = 0
|
||||||
|
bup = None
|
||||||
|
if os.path.exists(args.output):
|
||||||
|
bup = f"{args.output}.bup"
|
||||||
|
shutil.copyfile(args.output, bup)
|
||||||
|
with open(args.output, "r") as f:
|
||||||
|
reader = csv.reader(f)
|
||||||
|
for row in reader:
|
||||||
|
skip += 1
|
||||||
|
|
||||||
|
progress = tqdm(total=total, unit='row', unit_scale=2)
|
||||||
|
n = 0
|
||||||
|
with open(args.input, "r") as f:
|
||||||
|
with open(args.output, "w") as tf:
|
||||||
|
bupf = None
|
||||||
|
bupcsv = None
|
||||||
|
if bup is not None:
|
||||||
|
bupf = open(bup, "r")
|
||||||
|
bupcsv = csv.reader(bupf)
|
||||||
|
next(bupcsv)
|
||||||
|
try:
|
||||||
|
reader = csv.reader(f)
|
||||||
|
progress.update(1)
|
||||||
|
ru = csv.writer(tf, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
|
||||||
|
ru.writerow(['label', 'text'])
|
||||||
|
header = next(reader)
|
||||||
|
skipped = 1
|
||||||
|
for row in reader:
|
||||||
|
progress.update(1)
|
||||||
|
decision = row[0]
|
||||||
|
text = row[1]
|
||||||
|
if skipped < skip:
|
||||||
|
skipped += 1
|
||||||
|
already_translated = next(bupcsv)
|
||||||
|
ru.writerow(already_translated)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
translated_text = translator.translate(text, dest='ru')
|
||||||
|
ru.writerow([decision] + [translated_text.text])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Skipping line: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
finally:
|
||||||
|
if bupf is not None:
|
||||||
|
close(bupf)
|
0
src/web/__init__.py
Normal file
0
src/web/__init__.py
Normal file
42
src/web/server.py
Normal file
42
src/web/server.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tornado
|
||||||
|
from spam_detector_ai.prediction.predict import VotingSpamDetector
|
||||||
|
from src.logging.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
_spam_detector = VotingSpamDetector()
|
||||||
|
|
||||||
|
|
||||||
|
def _json(data) -> str:
|
||||||
|
return json.dumps(data)
|
||||||
|
|
||||||
|
def start(port: int) -> None:
|
||||||
|
logger.info("Starting...")
|
||||||
|
|
||||||
|
class CheckSpamHandler(tornado.web.RequestHandler):
|
||||||
|
def set_default_headers(self):
|
||||||
|
self.set_header("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
body = json.loads(self.request.body)
|
||||||
|
if not 'text' in body:
|
||||||
|
self.write_error(400, body=_json({"error": "text is not specified"}))
|
||||||
|
else:
|
||||||
|
r = json.dumps({"is_spam": _spam_detector.is_spam(body['text'])})
|
||||||
|
self.write(r)
|
||||||
|
|
||||||
|
async def start_web_server():
|
||||||
|
logger.info(f"Starting web server on port {port}")
|
||||||
|
app = tornado.web.Application(
|
||||||
|
[
|
||||||
|
(r"/check-spam", CheckSpamHandler),
|
||||||
|
],
|
||||||
|
template_path=os.path.join(os.path.dirname(__file__), "templates"),
|
||||||
|
static_path=os.path.join(os.path.dirname(__file__), "static"),
|
||||||
|
)
|
||||||
|
app.listen(port)
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
asyncio.run(start_web_server())
|
Loading…
Reference in New Issue
Block a user