Aryan Mishra commited on
Commit
a00fee9
·
1 Parent(s): dbd4e8b

Add CI, typed ORM models, and packaging cleanup

Browse files

Introduce repo-wide engineering guardrails with GitHub Actions CI (ruff, mypy, bandit, pytest), pre-commit hooks, editor config, and contributor/security docs. Refactor API persistence models into `api/models` with SQLAlchemy 2.0 typed mappings, wire imports accordingly, and align project packaging/test execution by adding `py.typed` markers, pytest pythonpath config, and Makefile/docs updates that remove ad-hoc `PYTHONPATH` usage. This also cleans generated artifacts from git, updates notebook/script paths to src-layout module execution, and refreshes project structure documentation.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .editorconfig +26 -0
  2. .gitattributes +2 -4
  3. .github/workflows/ci.yml +78 -0
  4. .gitignore +2 -0
  5. .pre-commit-config.yaml +33 -0
  6. CONTRIBUTING.md +89 -0
  7. LICENSE +21 -0
  8. Makefile +8 -5
  9. README.md +20 -16
  10. SECURITY.md +31 -0
  11. api/main.py +2 -2
  12. api/middleware/dependencies.py +3 -6
  13. api/models/__init__.py +0 -0
  14. api/models/db_models.py +55 -0
  15. api/py.typed +0 -0
  16. api/routes/predict.py +17 -23
  17. api/routes/results.py +9 -14
  18. api/schemas/db_models.py +0 -43
  19. api/schemas/schemas.py +2 -1
  20. api/services/lang_service.py +3 -2
  21. api/tasks/__init__.py +3 -4
  22. api/tasks/batch_tasks.py +9 -6
  23. docs/DEPLOYMENT.md +2 -2
  24. docs/TECH_STACK.md +10 -10
  25. frontend/py.typed +0 -0
  26. notebooks/{03_train_colab.ipynb → 02_train_colab.ipynb} +6 -6
  27. notebooks/{08_final_evaluation.ipynb → 05_final_evaluation.ipynb} +0 -0
  28. notebooks/README.md +16 -0
  29. pyproject.toml +6 -0
  30. scripts/README.md +13 -0
  31. scripts/download_data.py +12 -7
  32. scripts/drift_monitor.py +23 -22
  33. scripts/generate_notebooks.py +108 -35
  34. scripts/init_db.py +7 -4
  35. scripts/upload_models.py +4 -3
  36. src/absa/data/augmentation.py +6 -9
  37. src/absa/data/bio_tagger.py +3 -9
  38. src/absa/data/dataset.py +8 -6
  39. src/absa/data/hf_dataset.py +8 -12
  40. src/absa/data/hindi_loader.py +4 -5
  41. src/absa/data/lang_detect.py +2 -0
  42. src/absa/data/preprocess.py +1 -0
  43. src/absa/data/transliterate.py +3 -9
  44. src/absa/evaluation/benchmark_latency.py +11 -28
  45. src/absa/evaluation/cross_lingual_eval.py +11 -22
  46. src/absa/evaluation/final_eval.py +1 -1
  47. src/absa/models/baseline.py +17 -25
  48. src/absa/models/export_onnx.py +4 -10
  49. src/absa/models/train_aspect_extraction.py +12 -14
  50. src/absa/models/train_joint_absa.py +15 -24
.editorconfig ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # EditorConfig — https://editorconfig.org
2
+ root = true
3
+
4
+ [*]
5
+ charset = utf-8
6
+ end_of_line = lf
7
+ insert_final_newline = true
8
+ trim_trailing_whitespace = true
9
+
10
+ [*.{py,pyw}]
11
+ indent_style = space
12
+ indent_size = 4
13
+
14
+ [*.{json,yml,yaml,toml}]
15
+ indent_style = space
16
+ indent_size = 2
17
+
18
+ [Makefile]
19
+ indent_style = tab
20
+
21
+ [*.md]
22
+ trim_trailing_whitespace = false
23
+
24
+ [*.ipynb]
25
+ indent_style = space
26
+ indent_size = 2
.gitattributes CHANGED
@@ -5,9 +5,7 @@
5
  # ==========================================
6
  # EXCLUSIONS FROM LANGUAGE STATISTICS
7
  # 1. Archived/Backup Dashboard Code
8
- # The dashboard_backup directory contains a legacy Vite/React application
9
- # including large minified JS assets in dist/, CSS, and vendored node_modules.
10
- # We exclude this entire directory to prevent archived code from dominating statistics.
11
  dashboard_backup/** linguist-generated=true
12
  # 2. IDE and Tooling Hooks
13
  # The .opencode directory contains generated JS hooks for the workspace environment.
@@ -17,7 +15,7 @@ dashboard_backup/** linguist-generated=true
17
  # Notebooks are used for exploration and training on Colab, but the core active
18
  # application is the Python backend and FastAPI. Excluding these prevents notebooks
19
  # from misrepresenting the primary languages.
20
- ml/notebooks/*.ipynb linguist-generated=true
21
  # 4. Standard Build Artifacts & Vendored Dependencies
22
  # Ensures that any inadvertently committed build artifacts or vendor libraries
23
  # (like node_modules in subdirectories) do not skew language statistics.
 
5
  # ==========================================
6
  # EXCLUSIONS FROM LANGUAGE STATISTICS
7
  # 1. Archived/Backup Dashboard Code
8
+ # If a legacy dashboard_backup directory is reintroduced, exclude generated assets.
 
 
9
  dashboard_backup/** linguist-generated=true
10
  # 2. IDE and Tooling Hooks
11
  # The .opencode directory contains generated JS hooks for the workspace environment.
 
15
  # Notebooks are used for exploration and training on Colab, but the core active
16
  # application is the Python backend and FastAPI. Excluding these prevents notebooks
17
  # from misrepresenting the primary languages.
18
+ notebooks/*.ipynb linguist-generated=true
19
  # 4. Standard Build Artifacts & Vendored Dependencies
20
  # Ensures that any inadvertently committed build artifacts or vendor libraries
21
  # (like node_modules in subdirectories) do not skew language statistics.
.github/workflows/ci.yml ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ concurrency:
10
+ group: ci-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ env:
14
+ PYTHON_VERSION: "3.11"
15
+
16
+ jobs:
17
+ lint:
18
+ name: Lint (ruff)
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ env.PYTHON_VERSION }}
25
+ - name: Install ruff
26
+ run: pip install ruff==0.15.11
27
+ - name: Ruff check
28
+ run: ruff check api src/absa tests scripts
29
+ - name: Ruff format check
30
+ run: ruff format --check api src/absa tests scripts
31
+
32
+ typecheck:
33
+ name: Typecheck (mypy)
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ - uses: actions/setup-python@v5
38
+ with:
39
+ python-version: ${{ env.PYTHON_VERSION }}
40
+ - name: Install dev deps
41
+ run: |
42
+ pip install --upgrade pip
43
+ pip install -e ".[dev]"
44
+ - name: Mypy
45
+ run: mypy api src/absa
46
+
47
+ security:
48
+ name: Security (bandit)
49
+ runs-on: ubuntu-latest
50
+ steps:
51
+ - uses: actions/checkout@v4
52
+ - uses: actions/setup-python@v5
53
+ with:
54
+ python-version: ${{ env.PYTHON_VERSION }}
55
+ - name: Install bandit
56
+ run: pip install bandit
57
+ - name: Bandit scan
58
+ run: bandit -r api src/absa
59
+
60
+ test:
61
+ name: Tests (pytest) — py${{ matrix.python }}
62
+ runs-on: ubuntu-latest
63
+ strategy:
64
+ fail-fast: false
65
+ matrix:
66
+ python: ["3.10", "3.11"]
67
+ steps:
68
+ - uses: actions/checkout@v4
69
+ - uses: actions/setup-python@v5
70
+ with:
71
+ python-version: ${{ matrix.python }}
72
+ cache: pip
73
+ - name: Install package + dev deps
74
+ run: |
75
+ pip install --upgrade pip
76
+ pip install -e ".[dev]"
77
+ - name: Run tests
78
+ run: pytest tests/ -v
.gitignore CHANGED
@@ -14,6 +14,8 @@ __pycache__/
14
  .mypy_cache/
15
  .ruff_cache/
16
  .coverage
 
 
17
  htmlcov/
18
 
19
  # Virtual environment
 
14
  .mypy_cache/
15
  .ruff_cache/
16
  .coverage
17
+ .coverage.*
18
+ coverage.xml
19
  htmlcov/
20
 
21
  # Virtual environment
.pre-commit-config.yaml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.15.11
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix]
7
+ - id: ruff-format
8
+
9
+ - repo: https://github.com/pre-commit/pre-commit-hooks
10
+ rev: v4.6.0
11
+ hooks:
12
+ - id: check-yaml
13
+ exclude: ^docker/docker-compose.*\.ya?ml$
14
+ - id: check-json
15
+ - id: end-of-file-fixer
16
+ exclude: ^notebooks/
17
+ - id: trailing-whitespace
18
+ exclude: ^notebooks/
19
+ - id: check-added-large-files
20
+ args: ["--maxkb=512"] # ~data/model artifacts are DVC-tracked, not committed
21
+ - id: detect-private-key
22
+
23
+ - repo: https://github.com/asottile/pyupgrade
24
+ rev: v3.17.0
25
+ hooks:
26
+ - id: pyupgrade
27
+ args: [--py310-plus]
28
+
29
+ - repo: https://github.com/econchick/interrogate
30
+ rev: 1.7.0
31
+ hooks:
32
+ - id: interrogate
33
+ args: [-vv, -i, --fail-under=20, --exclude-module=__init__, --ignore-module=__main__]
CONTRIBUTING.md ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing to Multilingual-ABSA
2
+
3
+ Thanks for taking the time to contribute! This document outlines the workflow,
4
+ tooling, and conventions for building and shipping changes to this repository.
5
+
6
+ ## Table of Contents
7
+
8
+ - [Development Setup](#development-setup)
9
+ - [Project Layout](#project-layout)
10
+ - [Quality Gates](#quality-gates)
11
+ - [Workflow](#workflow)
12
+ - [Conventions](#conventions)
13
+ - [Commit Guidelines](#commit-guidelines)
14
+
15
+ ## Development Setup
16
+
17
+ ```bash
18
+ python -m venv .venv && source .venv/bin/activate
19
+ pip install -e ".[dev]"
20
+ ```
21
+
22
+ Install the pre-commit hooks (optional but recommended):
23
+
24
+ ```bash
25
+ pre-commit install
26
+ ```
27
+
28
+ ## Project Layout
29
+
30
+ ```
31
+ api/ # FastAPI REST service (routes, middleware, services, tasks, models, schemas)
32
+ src/absa/ # Core ML library (data, models, evaluation, training, utils) — src-layout
33
+ frontend/ # Streamlit dashboard
34
+ scripts/ # Operational/one-off utility scripts
35
+ notebooks/ # Exploration & Colab training notebooks
36
+ tests/ # Pytest suite (api/, web/, unit/)
37
+ docs/ # Project documentation
38
+ docker/ # Container definitions & compose files
39
+ monitoring/ # Prometheus / Grafana configuration
40
+ data/ # Datasets (DVC-tracked)
41
+ models/ # Model artifacts (DVC-tracked)
42
+ ```
43
+
44
+ ## Quality Gates
45
+
46
+ Every change must pass all of the following before being merged:
47
+
48
+ ```bash
49
+ make lint # ruff check api src/absa tests
50
+ make typecheck # mypy api src/absa
51
+ make security # bandit -r api src/absa
52
+ make test # pytest
53
+ ```
54
+
55
+ ## Workflow
56
+
57
+ 1. **Fork** the repository and create a branch from `main`:
58
+
59
+ ```bash
60
+ git checkout -b feature/<description>
61
+ ```
62
+
63
+ 2. Make focused, atomic changes — see [Commit Guidelines](#commit-guidelines).
64
+
65
+ 3. Run the [quality gates](#quality-gates) locally.
66
+
67
+ 4. Open a pull request describing **what** changed, **why**, and how you
68
+ verified it. Reference any related issues.
69
+
70
+ ## Conventions
71
+
72
+ - **Python** — target 3.10+. Format/lint is enforced by `ruff` (120-char lines,
73
+ `E`, `F`, `I`, `N`, `W` rule set). Type hints are checked by `mypy`.
74
+ - **Imports** — absolute imports only (`from absa.data import ...`,
75
+ `from api.routes import ...`); never rely on `sys.path` hacks in library code.
76
+ - **Models vs. schemas** — SQLAlchemy ORM models live in `api/models/`;
77
+ Pydantic request/response models live in `api/schemas/`.
78
+ - **Secrets** — never commit `.env` or real credentials. Add any new required
79
+ environment variables to `.env.example`.
80
+ - **Data** — datasets and model weights are versioned with DVC, not git.
81
+ Update `dvc.yaml` when preprocessing stages change.
82
+
83
+ ## Commit Guidelines
84
+
85
+ - Keep commits small, focused, and logically independent.
86
+ - Use the imperative mood: "Add batch status endpoint", not "Added endpoint".
87
+ - Prefix with the area when it aids scanning, e.g. `api:`, `frontend:`,
88
+ `data:`, `docs:`.
89
+ - Do not bundle unrelated changes (e.g. formatting + feature) in one commit.
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Multilingual-ABSA Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Makefile CHANGED
@@ -1,4 +1,4 @@
1
- .PHONY: install dev api frontend worker test lint typecheck security coverage docker-up docker-down clean
2
 
3
  # ── Setup ────────────────────────────────────────────────────────────────
4
  install:
@@ -19,19 +19,22 @@ worker:
19
 
20
  # ── Quality gates ────────────────────────────────────────────────────────
21
  test:
22
- PYTHONPATH=src pytest
23
 
24
  lint:
25
- PYTHONPATH=src ruff check api src/absa tests
 
 
 
26
 
27
  typecheck:
28
- PYTHONPATH=src mypy api src/absa
29
 
30
  security:
31
  bandit -r api src/absa
32
 
33
  coverage:
34
- PYTHONPATH=src pytest --cov=api --cov=absa --cov-report=term-missing
35
 
36
  # ── Docker ───────────────────────────────────────────────────────────────
37
  docker-up:
 
1
+ .PHONY: install dev api frontend worker test lint format typecheck security coverage docker-up docker-down clean
2
 
3
  # ── Setup ────────────────────────────────────────────────────────────────
4
  install:
 
19
 
20
  # ── Quality gates ────────────────────────────────────────────────────────
21
  test:
22
+ pytest
23
 
24
  lint:
25
+ ruff check api src/absa tests
26
+
27
+ format:
28
+ ruff format api src/absa tests
29
 
30
  typecheck:
31
+ mypy api src/absa
32
 
33
  security:
34
  bandit -r api src/absa
35
 
36
  coverage:
37
+ pytest --cov=api --cov=absa --cov-report=term-missing
38
 
39
  # ── Docker ───────────────────────────────────────────────────────────────
40
  docker-up:
README.md CHANGED
@@ -60,41 +60,42 @@ The system identifies **aspects** (specific features like "battery life", "sound
60
 
61
  ```
62
  .
63
- ├── api/ # FastAPI REST API (pure Python)
64
  │ ├── main.py # Entry point — uvicorn api.main:app
65
- │ ├── middleware/ # Rate limiting, metrics, DB deps
66
- │ ├── routes/ # /predict, /batch, /status, /health, /info
67
- │ ├── schemas/ # Pydantic models + SQLAlchemy ORM
 
68
  │ ├── services/ # ABSA inference pipeline, language detection
69
  │ └── tasks/ # Celery batch processing workers
70
- ├── src/absa/ # Core ML library (pure Python)
71
  │ ├── data/ # Loading, preprocessing, augmentation, transliteration
72
  │ ├── models/ # Training scripts (ONNX, Transformers, baselines)
73
  │ ├── evaluation/ # Cross-lingual eval, latency benchmarking
74
  │ ├── training/ # MLflow experiment tracking
75
- │ └── utils/ # Path configuration
76
  ├── frontend/ # Streamlit dashboard (pure Python, no HTML templates)
77
  │ ├── Home.py # Entry point — streamlit run frontend/Home.py
78
  │ ├── absa_client.py # Thin HTTP client for the FastAPI backend
79
  │ ├── ui.py # Native Streamlit UI helpers
80
  │ └── views/ # Pages: predict, admin (overview/batch/monitor)
81
- ├── docker/ # Containerisation
82
- │ ├── Dockerfile # Production API image
83
- │ ├── Dockerfile.prod # Production image (HuggingFace Hub model source)
84
- │ ├── docker-compose.yml # Full stack: API + worker + DB + Redis + monitoring
85
- │ └── docker-compose.prod.yml # Production overrides
86
- ├── tests/ # Test suite
87
  │ ├── api/ # API endpoint tests
88
  │ ├── web/ # Streamlit page + client tests
89
  │ └── unit/ # Unit tests (bio tagger, lang detect)
90
- ├── scripts/ # Utility scripts
 
91
  ├── monitoring/ # Prometheus + Grafana config
92
  ├── docs/ # Documentation
 
93
  ├── data/ # Datasets (managed by DVC)
94
- ├── models/ # ONNX model artifacts
 
95
  ├── .env.example # Environment variable template
96
  ├── dvc.yaml # DVC data pipeline
97
- ── pyproject.toml # Project metadata
 
98
  ```
99
 
100
  ---
@@ -177,7 +178,10 @@ dvc push # Upload to remote storage
177
  ### Run Tests
178
 
179
  ```bash
180
- PYTHONPATH=.:src pytest tests/ -v
 
 
 
181
  ```
182
 
183
  ### Run Full Stack (Docker)
 
60
 
61
  ```
62
  .
63
+ ├── api/ # FastAPI REST service
64
  │ ├── main.py # Entry point — uvicorn api.main:app
65
+ │ ├── models/ # SQLAlchemy ORM models (Review, AspectResult, BatchJob)
66
+ │ ├── middleware/ # Rate limiting, metrics (Prometheus), DB deps
67
+ │ ├── routes/ # /predict, /batch, /status, /download, /health, /info
68
+ │ ├── schemas/ # Pydantic request/response models
69
  │ ├── services/ # ABSA inference pipeline, language detection
70
  │ └── tasks/ # Celery batch processing workers
71
+ ├── src/absa/ # Core ML library (src-layout, pip-installable)
72
  │ ├── data/ # Loading, preprocessing, augmentation, transliteration
73
  │ ├── models/ # Training scripts (ONNX, Transformers, baselines)
74
  │ ├── evaluation/ # Cross-lingual eval, latency benchmarking
75
  │ ├── training/ # MLflow experiment tracking
76
+ │ └── utils/ # Path + environment configuration
77
  ├── frontend/ # Streamlit dashboard (pure Python, no HTML templates)
78
  │ ├── Home.py # Entry point — streamlit run frontend/Home.py
79
  │ ├── absa_client.py # Thin HTTP client for the FastAPI backend
80
  │ ├── ui.py # Native Streamlit UI helpers
81
  │ └── views/ # Pages: predict, admin (overview/batch/monitor)
82
+ ├── tests/ # Pytest suite
83
+ │ ├── conftest.py # sys.path bootstrap — no PYTHONPATH hacks required
 
 
 
 
84
  │ ├── api/ # API endpoint tests
85
  │ ├── web/ # Streamlit page + client tests
86
  │ └── unit/ # Unit tests (bio tagger, lang detect)
87
+ ├── scripts/ # Operational utility scripts
88
+ ├── notebooks/ # Exploration & Colab training notebooks (numbered 01–05)
89
  ├── monitoring/ # Prometheus + Grafana config
90
  ├── docs/ # Documentation
91
+ ├── docker/ # Containerisation
92
  ├── data/ # Datasets (managed by DVC)
93
+ ├── models/ # ONNX model artifacts (DVC-tracked)
94
+ ├── .github/workflows/ # CI pipeline (lint, typecheck, security, tests)
95
  ├── .env.example # Environment variable template
96
  ├── dvc.yaml # DVC data pipeline
97
+ ── pyproject.toml # Project metadata + tool config
98
+ └── Makefile # Developer command shortcuts
99
  ```
100
 
101
  ---
 
178
  ### Run Tests
179
 
180
  ```bash
181
+ make test # pytest
182
+ make lint # ruff
183
+ make typecheck # mypy
184
+ make security # bandit
185
  ```
186
 
187
  ### Run Full Stack (Docker)
SECURITY.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security Policy
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ Please **do not** open a public issue for security vulnerabilities. Instead,
6
+ report them privately to the maintainers so they can be triaged and fixed
7
+ before disclosure.
8
+
9
+ Include in your report:
10
+
11
+ - A description of the vulnerability and the affected endpoints/components.
12
+ - Steps to reproduce (if possible).
13
+ - Impact assessment.
14
+
15
+ ## Supported Versions
16
+
17
+ Security fixes are backported to the latest stable release. Older versions are
18
+ not actively patched — please upgrade to the current release.
19
+
20
+ ## Known Scope
21
+
22
+ This project runs a public JSON API and an admin dashboard. A current threat
23
+ model and mitigation checklist is maintained in
24
+ [docs/SECURITY.md](docs/SECURITY.md) — please review it before deploying to an
25
+ untrusted network.
26
+
27
+ ## Disclosure Timeline
28
+
29
+ - **Acknowledgement** — within 72 hours of the report.
30
+ - **Fix** — a patched release is published as soon as the fix is verified.
31
+ - **Disclosure** — public mention of the vulnerability after the fix ships.
api/main.py CHANGED
@@ -12,8 +12,8 @@ load_dotenv()
12
 
13
  from api.middleware.dependencies import engine # noqa: E402
14
  from api.middleware.metrics import instrumentator # noqa: E402
 
15
  from api.routes import predict, results # noqa: E402
16
- from api.schemas.db_models import Base # noqa: E402
17
  from api.services.absa_pipeline import pipeline # noqa: E402
18
 
19
 
@@ -41,7 +41,7 @@ app = FastAPI(
41
  )
42
 
43
  app.state.limiter = limiter
44
- app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
45
 
46
  app.add_middleware(
47
  CORSMiddleware,
 
12
 
13
  from api.middleware.dependencies import engine # noqa: E402
14
  from api.middleware.metrics import instrumentator # noqa: E402
15
+ from api.models.db_models import Base # noqa: E402
16
  from api.routes import predict, results # noqa: E402
 
17
  from api.services.absa_pipeline import pipeline # noqa: E402
18
 
19
 
 
41
  )
42
 
43
  app.state.limiter = limiter
44
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore[arg-type]
45
 
46
  app.add_middleware(
47
  CORSMiddleware,
api/middleware/dependencies.py CHANGED
@@ -1,17 +1,15 @@
1
  import os
 
 
2
  from sqlalchemy import create_engine
3
  from sqlalchemy.orm import sessionmaker
4
- from dotenv import load_dotenv
5
 
6
  load_dotenv()
7
 
8
  DATABASE_URL = os.getenv("DATABASE_URL")
9
 
10
  if not DATABASE_URL:
11
- raise RuntimeError(
12
- "DATABASE_URL environment variable is not set. "
13
- "Please set it in your .env file or environment."
14
- )
15
 
16
  connect_args = {}
17
  if DATABASE_URL.startswith("sqlite"):
@@ -21,7 +19,6 @@ engine = create_engine(DATABASE_URL, pool_pre_ping=True, connect_args=connect_ar
21
  SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
22
 
23
 
24
-
25
  def get_db():
26
  db = SessionLocal()
27
  try:
 
1
  import os
2
+
3
+ from dotenv import load_dotenv
4
  from sqlalchemy import create_engine
5
  from sqlalchemy.orm import sessionmaker
 
6
 
7
  load_dotenv()
8
 
9
  DATABASE_URL = os.getenv("DATABASE_URL")
10
 
11
  if not DATABASE_URL:
12
+ raise RuntimeError("DATABASE_URL environment variable is not set. Please set it in your .env file or environment.")
 
 
 
13
 
14
  connect_args = {}
15
  if DATABASE_URL.startswith("sqlite"):
 
19
  SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
20
 
21
 
 
22
  def get_db():
23
  db = SessionLocal()
24
  try:
api/models/__init__.py ADDED
File without changes
api/models/db_models.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLAlchemy ORM models for the API persistence layer.
2
+
3
+ Typed with SQLAlchemy 2.0 ``Mapped`` annotations so mypy (via the
4
+ ``sqlalchemy.ext.mypy.plugin``) can infer attribute types instead of
5
+ ``Column[...]``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from datetime import datetime, timezone
12
+
13
+ from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
14
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
15
+
16
+
17
+ def _utcnow() -> datetime:
18
+ return datetime.now(timezone.utc)
19
+
20
+
21
+ class Base(DeclarativeBase):
22
+ pass
23
+
24
+
25
+ class Review(Base):
26
+ __tablename__ = "reviews"
27
+
28
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
29
+ text: Mapped[str] = mapped_column(Text, nullable=False)
30
+ language: Mapped[str] = mapped_column(String(10), nullable=False)
31
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
32
+ processing_time_ms: Mapped[float] = mapped_column(Float, nullable=False)
33
+
34
+
35
+ class AspectResult(Base):
36
+ __tablename__ = "aspect_results"
37
+
38
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
39
+ review_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("reviews.id"), nullable=False)
40
+ aspect: Mapped[str] = mapped_column(String(255), nullable=False)
41
+ sentiment: Mapped[str] = mapped_column(String(50), nullable=False)
42
+ confidence: Mapped[float] = mapped_column(Float, nullable=False)
43
+ start_pos: Mapped[int] = mapped_column(Integer, nullable=False)
44
+ end_pos: Mapped[int] = mapped_column(Integer, nullable=False)
45
+
46
+
47
+ class BatchJob(Base):
48
+ __tablename__ = "batch_jobs"
49
+
50
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
51
+ status: Mapped[str] = mapped_column(String(50), nullable=False, default="queued")
52
+ total: Mapped[int] = mapped_column(Integer, nullable=False)
53
+ processed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
54
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
55
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
api/py.typed ADDED
File without changes
api/routes/predict.py CHANGED
@@ -1,17 +1,19 @@
1
- from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
2
- from sqlalchemy.orm import Session
3
- import pandas as pd
4
  import os
5
- import uuid
6
  import tempfile
7
  import time
8
- import re
 
 
 
 
9
 
10
- from api.schemas.schemas import ReviewInput, PredictionResponse, BatchJobResponse
11
- from api.schemas.db_models import Review, AspectResult, BatchJob
12
  from api.middleware.dependencies import get_db
 
 
13
  from api.services.absa_pipeline import pipeline
14
  from api.tasks.batch_tasks import process_batch
 
15
  router = APIRouter()
16
 
17
 
@@ -46,13 +48,13 @@ async def predict(request: ReviewInput, db: Session = Depends(get_db)):
46
  db.commit()
47
 
48
  return prediction
49
- except Exception as e:
50
  raise HTTPException(status_code=500, detail="Model inference failed. Please try again.")
51
 
52
 
53
  @router.post("/batch", response_model=BatchJobResponse)
54
  async def predict_batch(file: UploadFile = File(...), db: Session = Depends(get_db)):
55
- MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50MB
56
 
57
  if not file.filename or not file.filename.endswith(".csv"):
58
  raise HTTPException(status_code=422, detail="Only CSV files are allowed.")
@@ -62,7 +64,7 @@ async def predict_batch(file: UploadFile = File(...), db: Session = Depends(get_
62
 
63
  try:
64
  content = await file.read()
65
- if len(content) > MAX_UPLOAD_SIZE:
66
  raise HTTPException(status_code=422, detail="File exceeds 50MB maximum size.")
67
 
68
  # Create temp file to read
@@ -80,15 +82,11 @@ async def predict_batch(file: UploadFile = File(...), db: Session = Depends(get_
80
  df = pd.read_csv(tmp_path)
81
  if "text" not in df.columns:
82
  os.unlink(tmp_path)
83
- raise HTTPException(
84
- status_code=422, detail="CSV must contain a 'text' column."
85
- )
86
 
87
  if len(df) > 10000:
88
  os.unlink(tmp_path)
89
- raise HTTPException(
90
- status_code=422, detail="Max 10,000 rows allowed per batch."
91
- )
92
 
93
  job_id_obj = uuid.uuid4()
94
  job_id = str(job_id_obj)
@@ -99,20 +97,16 @@ async def predict_batch(file: UploadFile = File(...), db: Session = Depends(get_
99
  # Queue Celery task
100
  process_batch.delay(job_id, tmp_path)
101
 
102
- return BatchJobResponse(
103
- job_id=job_id, status="queued", total_reviews=len(df), processed=0
104
- )
105
  except HTTPException:
106
  raise
107
  except Exception:
108
- raise HTTPException(
109
- status_code=500, detail="Batch processing failed. Please try again."
110
- )
111
 
112
 
113
  @router.get("/status/{job_id}", response_model=BatchJobResponse)
114
  async def get_batch_status(job_id: str, db: Session = Depends(get_db)):
115
- if not re.match(r'^[a-fA-F0-9\-]{36}$', job_id):
116
  raise HTTPException(status_code=400, detail="Invalid job ID format")
117
  try:
118
  job_id_uuid = uuid.UUID(job_id)
 
 
 
 
1
  import os
2
+ import re
3
  import tempfile
4
  import time
5
+ import uuid
6
+
7
+ import pandas as pd
8
+ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
9
+ from sqlalchemy.orm import Session
10
 
 
 
11
  from api.middleware.dependencies import get_db
12
+ from api.models.db_models import AspectResult, BatchJob, Review
13
+ from api.schemas.schemas import BatchJobResponse, PredictionResponse, ReviewInput
14
  from api.services.absa_pipeline import pipeline
15
  from api.tasks.batch_tasks import process_batch
16
+
17
  router = APIRouter()
18
 
19
 
 
48
  db.commit()
49
 
50
  return prediction
51
+ except Exception:
52
  raise HTTPException(status_code=500, detail="Model inference failed. Please try again.")
53
 
54
 
55
  @router.post("/batch", response_model=BatchJobResponse)
56
  async def predict_batch(file: UploadFile = File(...), db: Session = Depends(get_db)):
57
+ max_upload_size = 50 * 1024 * 1024 # 50MB
58
 
59
  if not file.filename or not file.filename.endswith(".csv"):
60
  raise HTTPException(status_code=422, detail="Only CSV files are allowed.")
 
64
 
65
  try:
66
  content = await file.read()
67
+ if len(content) > max_upload_size:
68
  raise HTTPException(status_code=422, detail="File exceeds 50MB maximum size.")
69
 
70
  # Create temp file to read
 
82
  df = pd.read_csv(tmp_path)
83
  if "text" not in df.columns:
84
  os.unlink(tmp_path)
85
+ raise HTTPException(status_code=422, detail="CSV must contain a 'text' column.")
 
 
86
 
87
  if len(df) > 10000:
88
  os.unlink(tmp_path)
89
+ raise HTTPException(status_code=422, detail="Max 10,000 rows allowed per batch.")
 
 
90
 
91
  job_id_obj = uuid.uuid4()
92
  job_id = str(job_id_obj)
 
97
  # Queue Celery task
98
  process_batch.delay(job_id, tmp_path)
99
 
100
+ return BatchJobResponse(job_id=job_id, status="queued", total_reviews=len(df), processed=0)
 
 
101
  except HTTPException:
102
  raise
103
  except Exception:
104
+ raise HTTPException(status_code=500, detail="Batch processing failed. Please try again.")
 
 
105
 
106
 
107
  @router.get("/status/{job_id}", response_model=BatchJobResponse)
108
  async def get_batch_status(job_id: str, db: Session = Depends(get_db)):
109
+ if not re.match(r"^[a-fA-F0-9\-]{36}$", job_id):
110
  raise HTTPException(status_code=400, detail="Invalid job ID format")
111
  try:
112
  job_id_uuid = uuid.UUID(job_id)
api/routes/results.py CHANGED
@@ -1,9 +1,15 @@
1
- from fastapi import APIRouter
2
  import os
 
 
3
  from typing import Dict
4
 
 
 
 
5
  router = APIRouter()
6
 
 
 
7
 
8
  @router.get("/health")
9
  async def health_check() -> Dict[str, str]:
@@ -20,25 +26,14 @@ async def get_info() -> Dict[str, str]:
20
  "max_batch_size": os.getenv("MAX_BATCH_SIZE", "10000"),
21
  }
22
 
23
- from fastapi import HTTPException
24
- from fastapi.responses import FileResponse
25
- from pathlib import Path
26
- import re
27
- import os
28
-
29
- _RESULTS_DIR = Path("data/results").resolve()
30
 
31
  @router.get("/download/{job_id}")
32
  async def download_result(job_id: str):
33
- if not re.match(r'^[a-fA-F0-9\-]{36}$', job_id):
34
  raise HTTPException(status_code=400, detail="Invalid job ID format")
35
  resolved = (_RESULTS_DIR / f"{job_id}.csv").resolve()
36
  if not str(resolved).startswith(str(_RESULTS_DIR)):
37
  raise HTTPException(status_code=400, detail="Invalid job ID")
38
  if not resolved.exists():
39
  raise HTTPException(status_code=404, detail="Result file not found")
40
- return FileResponse(
41
- path=resolved,
42
- filename=f"absa_results_{job_id}.csv",
43
- media_type="text/csv"
44
- )
 
 
1
  import os
2
+ import re
3
+ from pathlib import Path
4
  from typing import Dict
5
 
6
+ from fastapi import APIRouter, HTTPException
7
+ from fastapi.responses import FileResponse
8
+
9
  router = APIRouter()
10
 
11
+ _RESULTS_DIR = Path("data/results").resolve()
12
+
13
 
14
  @router.get("/health")
15
  async def health_check() -> Dict[str, str]:
 
26
  "max_batch_size": os.getenv("MAX_BATCH_SIZE", "10000"),
27
  }
28
 
 
 
 
 
 
 
 
29
 
30
  @router.get("/download/{job_id}")
31
  async def download_result(job_id: str):
32
+ if not re.match(r"^[a-fA-F0-9\-]{36}$", job_id):
33
  raise HTTPException(status_code=400, detail="Invalid job ID format")
34
  resolved = (_RESULTS_DIR / f"{job_id}.csv").resolve()
35
  if not str(resolved).startswith(str(_RESULTS_DIR)):
36
  raise HTTPException(status_code=400, detail="Invalid job ID")
37
  if not resolved.exists():
38
  raise HTTPException(status_code=404, detail="Result file not found")
39
+ return FileResponse(path=resolved, filename=f"absa_results_{job_id}.csv", media_type="text/csv")
 
 
 
 
api/schemas/db_models.py DELETED
@@ -1,43 +0,0 @@
1
- from sqlalchemy import Column, String, Integer, Float, DateTime, ForeignKey, Text, Uuid
2
- from sqlalchemy.orm import declarative_base
3
- import uuid
4
- from datetime import datetime, timezone
5
-
6
- Base = declarative_base()
7
-
8
-
9
- class Review(Base):
10
- __tablename__ = "reviews"
11
-
12
- id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
13
- text = Column(Text, nullable=False)
14
- language = Column(String(10), nullable=False)
15
- created_at = Column(
16
- DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
17
- )
18
- processing_time_ms = Column(Float, nullable=False)
19
-
20
-
21
- class AspectResult(Base):
22
- __tablename__ = "aspect_results"
23
-
24
- id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
25
- review_id = Column(Uuid(as_uuid=True), ForeignKey("reviews.id"), nullable=False)
26
- aspect = Column(String(255), nullable=False)
27
- sentiment = Column(String(50), nullable=False)
28
- confidence = Column(Float, nullable=False)
29
- start_pos = Column(Integer, nullable=False)
30
- end_pos = Column(Integer, nullable=False)
31
-
32
-
33
- class BatchJob(Base):
34
- __tablename__ = "batch_jobs"
35
-
36
- id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
37
- status = Column(String(50), nullable=False, default="queued")
38
- total = Column(Integer, nullable=False)
39
- processed = Column(Integer, nullable=False, default=0)
40
- created_at = Column(
41
- DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
42
- )
43
- completed_at = Column(DateTime(timezone=True), nullable=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/schemas/schemas.py CHANGED
@@ -1,5 +1,6 @@
 
 
1
  from pydantic import BaseModel, ConfigDict, Field
2
- from typing import Optional, List
3
 
4
 
5
  class ReviewInput(BaseModel):
 
1
+ from typing import List, Optional
2
+
3
  from pydantic import BaseModel, ConfigDict, Field
 
4
 
5
 
6
  class ReviewInput(BaseModel):
api/services/lang_service.py CHANGED
@@ -1,6 +1,7 @@
1
- import fasttext
2
  from pathlib import Path
3
 
 
 
4
 
5
  class LanguageService:
6
  def __init__(self):
@@ -15,7 +16,7 @@ class LanguageService:
15
  def detect_language(self, text: str) -> str:
16
  if self.model:
17
  predictions = self.model.predict(text.replace("\n", " "), k=1)
18
- lang = predictions[0][0].replace("__label__", "")
19
  if lang in ["en", "hi"]:
20
  return lang
21
  # Default to en if unknown or other
 
 
1
  from pathlib import Path
2
 
3
+ import fasttext
4
+
5
 
6
  class LanguageService:
7
  def __init__(self):
 
16
  def detect_language(self, text: str) -> str:
17
  if self.model:
18
  predictions = self.model.predict(text.replace("\n", " "), k=1)
19
+ lang: str = predictions[0][0].replace("__label__", "")
20
  if lang in ["en", "hi"]:
21
  return lang
22
  # Default to en if unknown or other
api/tasks/__init__.py CHANGED
@@ -1,11 +1,10 @@
1
- from celery import Celery
2
  import os
3
 
 
 
4
  redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
5
 
6
- celery_app = Celery(
7
- "absa_tasks", broker=redis_url, backend=redis_url.replace("/0", "/1")
8
- )
9
 
10
  celery_app.conf.update(
11
  task_serializer="json",
 
 
1
  import os
2
 
3
+ from celery import Celery
4
+
5
  redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
6
 
7
+ celery_app = Celery("absa_tasks", broker=redis_url, backend=redis_url.replace("/0", "/1"))
 
 
8
 
9
  celery_app.conf.update(
10
  task_serializer="json",
api/tasks/batch_tasks.py CHANGED
@@ -1,12 +1,14 @@
1
- from api.tasks import celery_app
2
- from api.services.absa_pipeline import pipeline
3
- from api.middleware.dependencies import SessionLocal
4
- from api.schemas.db_models import BatchJob, AspectResult, Review
5
- import pandas as pd
6
- import os
7
  import csv
 
8
  from datetime import datetime, timezone
9
 
 
 
 
 
 
 
 
10
 
11
  @celery_app.task(bind=True)
12
  def process_batch(self, job_id: str, file_path: str):
@@ -118,6 +120,7 @@ def process_batch(self, job_id: str, file_path: str):
118
  job.status = "failed"
119
  db.commit()
120
  import logging
 
121
  logging.exception("Batch processing failed for job %s", job_id)
122
  finally:
123
  # Clean up temp file
 
 
 
 
 
 
 
1
  import csv
2
+ import os
3
  from datetime import datetime, timezone
4
 
5
+ import pandas as pd
6
+
7
+ from api.middleware.dependencies import SessionLocal
8
+ from api.models.db_models import AspectResult, BatchJob, Review
9
+ from api.services.absa_pipeline import pipeline
10
+ from api.tasks import celery_app
11
+
12
 
13
  @celery_app.task(bind=True)
14
  def process_batch(self, job_id: str, file_path: str):
 
120
  job.status = "failed"
121
  db.commit()
122
  import logging
123
+
124
  logging.exception("Batch processing failed for job %s", job_id)
125
  finally:
126
  # Clean up temp file
docs/DEPLOYMENT.md CHANGED
@@ -27,8 +27,8 @@
27
  # Backend
28
  cp .env.example .env
29
  python -m venv .venv && source .venv/bin/activate
30
- pip install -r requirements.txt
31
- uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
32
  # API at http://localhost:8000, docs at http://localhost:8000/docs
33
 
34
  # MLflow
 
27
  # Backend
28
  cp .env.example .env
29
  python -m venv .venv && source .venv/bin/activate
30
+ pip install -e ".[dev]"
31
+ uvicorn api.main:app --reload --host 0.0.0.0 --port 8000
32
  # API at http://localhost:8000, docs at http://localhost:8000/docs
33
 
34
  # MLflow
docs/TECH_STACK.md CHANGED
@@ -18,25 +18,25 @@
18
 
19
  | Technology | Version | Purpose | Where Used |
20
  |------------|---------|---------|------------|
21
- | **PyTorch** | 2.3.0 | Deep learning framework | `src/models/` training |
22
  | **Transformers** | 4.39.3 | Model zoo, training, tokenization | All ML scripts |
23
  | **XLM-RoBERTa** | base | Multilingual encoder | `FacebookAI/xlm-roberta-base` |
24
  | **ONNX Runtime** | 1.18.0 | Production inference | `api/services/absa_pipeline.py` |
25
- | **Optimum** | 1.19.0 | ONNX export bridge | `src/models/export_onnx.py` |
26
  | **optimum-onnx** | (bundled) | ONNX runtime models | `ORTModelForTokenClassification`, `ORTModelForSequenceClassification` |
27
- | **PEFT** | 0.10.0 | Parameter-efficient fine-tuning | `src/models/train_qlora.py` (LoRA) |
28
- | **scikit-learn** | 1.4.2 | Metrics + baseline | `src/models/baseline.py`, `train_sentiment.py` |
29
- | **Datasets** | 2.19.0 | Data loading | `src/data/hf_dataset.py` |
30
- | **seqeval** | 1.2.2 | BIO tagging evaluation | `src/models/train_aspect_extraction.py` |
31
- | **fasttext-predict** | 0.9.2.4 | Language identification | `src/data/lang_detect.py`, `api/services/lang_service.py` |
32
- | **indic-nlp-library** | (git) | Devanagari transliteration | `src/data/transliterate.py` |
33
- | **nlpaug** | 1.1.11 | Text augmentation | `src/data/augmentation.py` |
34
 
35
  ## MLOps Stack
36
 
37
  | Technology | Version | Purpose | Where Used |
38
  |------------|---------|---------|------------|
39
- | **MLflow** | 2.13.0 | Experiment tracking | `src/training/mlflow_utils.py`, all `src/models/` |
40
  | **DVC** | 3.51.1 | Data version control | `dvc.yaml`, `.dvc/` |
41
  | **Evidently AI** | 0.4.30 | Data drift monitoring | `scripts/drift_monitor.py` |
42
  | **Prometheus** | latest | Metrics collection | `monitoring/prometheus.yml` |
 
18
 
19
  | Technology | Version | Purpose | Where Used |
20
  |------------|---------|---------|------------|
21
+ | **PyTorch** | 2.3.0 | Deep learning framework | `src/absa/models/` training |
22
  | **Transformers** | 4.39.3 | Model zoo, training, tokenization | All ML scripts |
23
  | **XLM-RoBERTa** | base | Multilingual encoder | `FacebookAI/xlm-roberta-base` |
24
  | **ONNX Runtime** | 1.18.0 | Production inference | `api/services/absa_pipeline.py` |
25
+ | **Optimum** | 1.19.0 | ONNX export bridge | `src/absa/models/export_onnx.py` |
26
  | **optimum-onnx** | (bundled) | ONNX runtime models | `ORTModelForTokenClassification`, `ORTModelForSequenceClassification` |
27
+ | **PEFT** | 0.10.0 | Parameter-efficient fine-tuning | `src/absa/models/train_qlora.py` (LoRA) |
28
+ | **scikit-learn** | 1.4.2 | Metrics + baseline | `src/absa/models/baseline.py`, `train_sentiment.py` |
29
+ | **Datasets** | 2.19.0 | Data loading | `src/absa/data/hf_dataset.py` |
30
+ | **seqeval** | 1.2.2 | BIO tagging evaluation | `src/absa/models/train_aspect_extraction.py` |
31
+ | **fasttext-predict** | 0.9.2.4 | Language identification | `src/absa/data/lang_detect.py`, `api/services/lang_service.py` |
32
+ | **indic-nlp-library** | (git) | Devanagari transliteration | `src/absa/data/transliterate.py` |
33
+ | **nlpaug** | 1.1.11 | Text augmentation | `src/absa/data/augmentation.py` |
34
 
35
  ## MLOps Stack
36
 
37
  | Technology | Version | Purpose | Where Used |
38
  |------------|---------|---------|------------|
39
+ | **MLflow** | 2.13.0 | Experiment tracking | `src/absa/training/mlflow_utils.py`, all `src/absa/models/` |
40
  | **DVC** | 3.51.1 | Data version control | `dvc.yaml`, `.dvc/` |
41
  | **Evidently AI** | 0.4.30 | Data drift monitoring | `scripts/drift_monitor.py` |
42
  | **Prometheus** | latest | Metrics collection | `monitoring/prometheus.yml` |
frontend/py.typed ADDED
File without changes
notebooks/{03_train_colab.ipynb → 02_train_colab.ipynb} RENAMED
@@ -17,7 +17,7 @@
17
  "source": [
18
  "!git clone https://github.com/Aryanmishra-dev/Multilingual-Absa.git\n",
19
  "%cd Multilingual-Absa\n",
20
- "!pip install -r requirements.txt\n"
21
  ]
22
  },
23
  {
@@ -49,7 +49,7 @@
49
  "outputs": [],
50
  "source": [
51
  "# Prepare dataset\n",
52
- "!PYTHONPATH=. python src/data/hf_dataset.py\n"
53
  ]
54
  },
55
  {
@@ -59,7 +59,7 @@
59
  "outputs": [],
60
  "source": [
61
  "# Run Aspect Extraction Training\n",
62
- "!PYTHONPATH=. python src/models/train_aspect_extraction.py\n"
63
  ]
64
  },
65
  {
@@ -69,7 +69,7 @@
69
  "outputs": [],
70
  "source": [
71
  "# Run Sentiment Classification Training\n",
72
- "!PYTHONPATH=. python src/models/train_sentiment.py\n"
73
  ]
74
  },
75
  {
@@ -79,7 +79,7 @@
79
  "outputs": [],
80
  "source": [
81
  "# Run Baseline as well\n",
82
- "!PYTHONPATH=. python src/models/baseline.py\n"
83
  ]
84
  },
85
  {
@@ -89,7 +89,7 @@
89
  "outputs": [],
90
  "source": [
91
  "# Cross-lingual Evaluation\n",
92
- "!PYTHONPATH=. python src/evaluation/cross_lingual_eval.py\n"
93
  ]
94
  },
95
  {
 
17
  "source": [
18
  "!git clone https://github.com/Aryanmishra-dev/Multilingual-Absa.git\n",
19
  "%cd Multilingual-Absa\n",
20
+ "!pip install .\n"
21
  ]
22
  },
23
  {
 
49
  "outputs": [],
50
  "source": [
51
  "# Prepare dataset\n",
52
+ "!PYTHONPATH=src python -m absa.data.hf_dataset\n"
53
  ]
54
  },
55
  {
 
59
  "outputs": [],
60
  "source": [
61
  "# Run Aspect Extraction Training\n",
62
+ "!PYTHONPATH=src python -m absa.models.train_aspect_extraction\n"
63
  ]
64
  },
65
  {
 
69
  "outputs": [],
70
  "source": [
71
  "# Run Sentiment Classification Training\n",
72
+ "!PYTHONPATH=src python -m absa.models.train_sentiment\n"
73
  ]
74
  },
75
  {
 
79
  "outputs": [],
80
  "source": [
81
  "# Run Baseline as well\n",
82
+ "!PYTHONPATH=src python -m absa.models.baseline\n"
83
  ]
84
  },
85
  {
 
89
  "outputs": [],
90
  "source": [
91
  "# Cross-lingual Evaluation\n",
92
+ "!PYTHONPATH=src python -m absa.evaluation.cross_lingual_eval\n"
93
  ]
94
  },
95
  {
notebooks/{08_final_evaluation.ipynb → 05_final_evaluation.ipynb} RENAMED
File without changes
notebooks/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Notebooks
2
+
3
+ Exploratory analysis and Colab training notebooks. Run them in order. The
4
+ first is best run locally; the training ones expect a Colab (T4+/A100) or
5
+ CUDA GPU environment.
6
+
7
+ | Notebook | Purpose |
8
+ |----------|---------|
9
+ | `01_data_exploration.ipynb` | Dataset overview, language distribution, label stats |
10
+ | `02_train_colab.ipynb` | End-to-end training (aspect extraction + sentiment) on Colab |
11
+ | `03_model_comparison.ipynb` | Compare trained runs from the MLflow tracking server |
12
+ | `04_qlora_colab.ipynb` | QLoRA 4-bit fine-tuning of the sequence classifier |
13
+ | `05_final_evaluation.ipynb` | Final cross-lingual evaluation and latency benchmarks |
14
+
15
+ These notebooks are generated/updated from `scripts/generate_notebooks.py`
16
+ where applicable.
pyproject.toml CHANGED
@@ -75,9 +75,14 @@ dev = [
75
  where = ["src", "."]
76
  include = ["absa*", "api*"]
77
 
 
 
 
 
78
  [tool.pytest.ini_options]
79
  testpaths = ["tests"]
80
  python_files = ["test_*.py"]
 
81
  asyncio_mode = "auto"
82
  asyncio_default_fixture_loop_scope = "function"
83
  addopts = "--cov=api --cov=absa --cov-report=term-missing --cov-report=xml"
@@ -92,6 +97,7 @@ extend-ignore = ["N999"] # module file name style
92
 
93
  [tool.mypy]
94
  python_version = "3.10"
 
95
  warn_return_any = true
96
  warn_unused_configs = true
97
  ignore_missing_imports = true
 
75
  where = ["src", "."]
76
  include = ["absa*", "api*"]
77
 
78
+ [tool.setuptools.package-data]
79
+ absa = ["py.typed"]
80
+ api = ["py.typed"]
81
+
82
  [tool.pytest.ini_options]
83
  testpaths = ["tests"]
84
  python_files = ["test_*.py"]
85
+ pythonpath = [".", "src"]
86
  asyncio_mode = "auto"
87
  asyncio_default_fixture_loop_scope = "function"
88
  addopts = "--cov=api --cov=absa --cov-report=term-missing --cov-report=xml"
 
97
 
98
  [tool.mypy]
99
  python_version = "3.10"
100
+ plugins = ["sqlalchemy.ext.mypy.plugin"]
101
  warn_return_any = true
102
  warn_unused_configs = true
103
  ignore_missing_imports = true
scripts/README.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Scripts
2
+
3
+ Operational and one-off utility scripts. Most are invoked manually or via
4
+ `Makefile`/`dvc.yaml` targets. Run from the repository root.
5
+
6
+ | Script | Purpose |
7
+ |--------|---------|
8
+ | `download_data.py` | Fetch fastText LID model, SemEval, and Amazon Hindi datasets |
9
+ | `upload_models.py` | Push trained model artifacts to HuggingFace Hub |
10
+ | `generate_notebooks.py` | Regenerate Colab notebook scaffolding |
11
+ | `mlflow_ui.sh` | Launch the MLflow tracking UI |
12
+ | `drift_monitor.py` | Evidently data-drift monitoring on live predictions |
13
+ | `init_db.py` | Create database tables from the SQLAlchemy models |
scripts/download_data.py CHANGED
@@ -1,7 +1,10 @@
1
  import os
2
  import urllib.request
 
3
  from datasets import load_dataset
4
- from absa.utils.config import DATA_DIR, RAW_DIR, FASTTEXT_MODEL_PATH
 
 
5
 
6
  def download_fasttext():
7
  url = "https://dl.fbaipublicfiles.com/fasttext/supervised-models/lid.176.ftz"
@@ -13,31 +16,33 @@ def download_fasttext():
13
  print("fastText LID model already exists.")
14
  print(f"fastText model size: {os.path.getsize(FASTTEXT_MODEL_PATH) / 1024 / 1024:.2f} MB")
15
 
 
16
  def download_semeval():
17
  print("Downloading SemEval datasets...")
18
  restaurants = load_dataset("tomaarsen/setfit-absa-semeval-restaurants")
19
  laptops = load_dataset("tomaarsen/setfit-absa-semeval-laptops")
20
-
21
  rest_path = RAW_DIR / "semeval_restaurants"
22
  lap_path = RAW_DIR / "semeval_laptops"
23
-
24
  restaurants.save_to_disk(str(rest_path))
25
  laptops.save_to_disk(str(lap_path))
26
-
27
  print(f"SemEval Restaurants train samples: {len(restaurants['train'])}")
28
  print(f"SemEval Laptops train samples: {len(laptops['train'])}")
29
 
 
30
  def download_amazon_hindi():
31
  print("Downloading Amazon Hindi dataset...")
32
- ds = load_dataset("ai4bharat/IndicSentiment", "translation-hi",
33
- trust_remote_code=True, split="test")
34
-
35
  amz_path = RAW_DIR / "amazon_hindi"
36
  os.makedirs(amz_path, exist_ok=True)
37
  file_path = amz_path / "hindi_sentiment.jsonl"
38
  ds.to_json(str(file_path))
39
  print(f"Downloaded {len(ds)} Hindi samples")
40
 
 
41
  if __name__ == "__main__":
42
  os.makedirs(RAW_DIR, exist_ok=True)
43
  download_fasttext()
 
1
  import os
2
  import urllib.request
3
+
4
  from datasets import load_dataset
5
+
6
+ from absa.utils.config import FASTTEXT_MODEL_PATH, RAW_DIR
7
+
8
 
9
  def download_fasttext():
10
  url = "https://dl.fbaipublicfiles.com/fasttext/supervised-models/lid.176.ftz"
 
16
  print("fastText LID model already exists.")
17
  print(f"fastText model size: {os.path.getsize(FASTTEXT_MODEL_PATH) / 1024 / 1024:.2f} MB")
18
 
19
+
20
  def download_semeval():
21
  print("Downloading SemEval datasets...")
22
  restaurants = load_dataset("tomaarsen/setfit-absa-semeval-restaurants")
23
  laptops = load_dataset("tomaarsen/setfit-absa-semeval-laptops")
24
+
25
  rest_path = RAW_DIR / "semeval_restaurants"
26
  lap_path = RAW_DIR / "semeval_laptops"
27
+
28
  restaurants.save_to_disk(str(rest_path))
29
  laptops.save_to_disk(str(lap_path))
30
+
31
  print(f"SemEval Restaurants train samples: {len(restaurants['train'])}")
32
  print(f"SemEval Laptops train samples: {len(laptops['train'])}")
33
 
34
+
35
  def download_amazon_hindi():
36
  print("Downloading Amazon Hindi dataset...")
37
+ ds = load_dataset("ai4bharat/IndicSentiment", "translation-hi", trust_remote_code=True, split="test")
38
+
 
39
  amz_path = RAW_DIR / "amazon_hindi"
40
  os.makedirs(amz_path, exist_ok=True)
41
  file_path = amz_path / "hindi_sentiment.jsonl"
42
  ds.to_json(str(file_path))
43
  print(f"Downloaded {len(ds)} Hindi samples")
44
 
45
+
46
  if __name__ == "__main__":
47
  os.makedirs(RAW_DIR, exist_ok=True)
48
  download_fasttext()
scripts/drift_monitor.py CHANGED
@@ -1,16 +1,17 @@
1
  import os
2
- import pandas as pd
3
  from datetime import datetime, timedelta
 
4
  import mlflow
5
- from evidently.report import Report
6
  from evidently.metric_preset import DataDriftPreset, TextOverviewPreset
 
7
  from sqlalchemy import create_engine
8
- import uuid
9
 
10
  def main():
11
  # Attempt to fetch database URL, fallback to sqlite for local tests
12
  db_url = os.getenv("DATABASE_URL", "sqlite:///./test.db")
13
-
14
  # We would normally load the reference data (e.g. from training data CSV)
15
  # For this script, we'll assume a local path or create a dummy reference if missing
16
  ref_path = "data/reference.csv"
@@ -18,50 +19,49 @@ def main():
18
  ref_df = pd.read_csv(ref_path)
19
  else:
20
  print(f"Reference data not found at {ref_path}. Creating dummy reference data for testing.")
21
- ref_df = pd.DataFrame({
22
- "text": ["This is great", "I hate this", "Neutral statement"],
23
- "language": ["en", "en", "en"]
24
- })
25
-
26
  try:
27
  # Load production data from the last 7 days
28
  engine = create_engine(db_url)
29
  seven_days_ago = datetime.now() - timedelta(days=7)
30
-
31
  # Load directly from SQLAlchemy using pandas with parameterized query
32
  query = "SELECT text, language FROM reviews WHERE created_at >= %(cutoff)s"
33
  curr_df = pd.read_sql(query, engine, params={"cutoff": seven_days_ago})
34
  except Exception as e:
35
  print(f"Failed to fetch production data: {e}")
36
  curr_df = pd.DataFrame(columns=["text", "language"])
37
-
38
  if len(curr_df) < 50:
39
- print(f"Not enough data to run drift monitor (found {len(curr_df)} records, need at least 50). Exiting gracefully.")
 
 
 
40
  return
41
 
42
  # Run Evidently report
43
  print("Running Evidently drift report...")
44
- report = Report(metrics=[
45
- DataDriftPreset(),
46
- TextOverviewPreset(column_name="text")
47
- ])
48
-
49
  report.run(reference_data=ref_df, current_data=curr_df)
50
-
51
  # Create monitoring/reports dir if missing
52
  os.makedirs("monitoring/reports", exist_ok=True)
53
-
54
  report_path = f"monitoring/reports/drift_{datetime.now().strftime('%Y%m%d')}.html"
55
  report.save_html(report_path)
56
  print(f"Report saved to {report_path}")
57
-
58
  # Extract drift metrics as a dict
59
  report_dict = report.as_dict()
60
-
61
  # Simplified check for drift (using Dataset Drift metric from DataDriftPreset)
62
  dataset_drift = report_dict["metrics"][0]["result"]["dataset_drift"]
63
  drift_share = report_dict["metrics"][0]["result"]["drift_share"]
64
-
65
  if dataset_drift and drift_share > 0.3:
66
  print(f"⚠️ Drift detected — consider retraining. Drift share: {drift_share:.2f}")
67
  try:
@@ -72,5 +72,6 @@ def main():
72
  except Exception as e:
73
  print(f"Failed to log warning to MLflow: {e}")
74
 
 
75
  if __name__ == "__main__":
76
  main()
 
1
  import os
 
2
  from datetime import datetime, timedelta
3
+
4
  import mlflow
5
+ import pandas as pd
6
  from evidently.metric_preset import DataDriftPreset, TextOverviewPreset
7
+ from evidently.report import Report
8
  from sqlalchemy import create_engine
9
+
10
 
11
  def main():
12
  # Attempt to fetch database URL, fallback to sqlite for local tests
13
  db_url = os.getenv("DATABASE_URL", "sqlite:///./test.db")
14
+
15
  # We would normally load the reference data (e.g. from training data CSV)
16
  # For this script, we'll assume a local path or create a dummy reference if missing
17
  ref_path = "data/reference.csv"
 
19
  ref_df = pd.read_csv(ref_path)
20
  else:
21
  print(f"Reference data not found at {ref_path}. Creating dummy reference data for testing.")
22
+ ref_df = pd.DataFrame(
23
+ {"text": ["This is great", "I hate this", "Neutral statement"], "language": ["en", "en", "en"]}
24
+ )
25
+
 
26
  try:
27
  # Load production data from the last 7 days
28
  engine = create_engine(db_url)
29
  seven_days_ago = datetime.now() - timedelta(days=7)
30
+
31
  # Load directly from SQLAlchemy using pandas with parameterized query
32
  query = "SELECT text, language FROM reviews WHERE created_at >= %(cutoff)s"
33
  curr_df = pd.read_sql(query, engine, params={"cutoff": seven_days_ago})
34
  except Exception as e:
35
  print(f"Failed to fetch production data: {e}")
36
  curr_df = pd.DataFrame(columns=["text", "language"])
37
+
38
  if len(curr_df) < 50:
39
+ print(
40
+ f"Not enough data to run drift monitor "
41
+ f"(found {len(curr_df)} records, need at least 50). Exiting gracefully."
42
+ )
43
  return
44
 
45
  # Run Evidently report
46
  print("Running Evidently drift report...")
47
+ report = Report(metrics=[DataDriftPreset(), TextOverviewPreset(column_name="text")])
48
+
 
 
 
49
  report.run(reference_data=ref_df, current_data=curr_df)
50
+
51
  # Create monitoring/reports dir if missing
52
  os.makedirs("monitoring/reports", exist_ok=True)
53
+
54
  report_path = f"monitoring/reports/drift_{datetime.now().strftime('%Y%m%d')}.html"
55
  report.save_html(report_path)
56
  print(f"Report saved to {report_path}")
57
+
58
  # Extract drift metrics as a dict
59
  report_dict = report.as_dict()
60
+
61
  # Simplified check for drift (using Dataset Drift metric from DataDriftPreset)
62
  dataset_drift = report_dict["metrics"][0]["result"]["dataset_drift"]
63
  drift_share = report_dict["metrics"][0]["result"]["drift_share"]
64
+
65
  if dataset_drift and drift_share > 0.3:
66
  print(f"⚠️ Drift detected — consider retraining. Drift share: {drift_share:.2f}")
67
  try:
 
72
  except Exception as e:
73
  print(f"Failed to log warning to MLflow: {e}")
74
 
75
+
76
  if __name__ == "__main__":
77
  main()
scripts/generate_notebooks.py CHANGED
@@ -1,17 +1,20 @@
1
  import json
2
  from pathlib import Path
3
 
 
4
  def create_notebook(filename: str, cells_content: list):
5
  cells = []
6
  for content, cell_type in cells_content:
7
- cells.append({
8
- "cell_type": cell_type,
9
- "metadata": {},
10
- "execution_count": None if cell_type == "code" else None,
11
- "outputs": [] if cell_type == "code" else None,
12
- "source": [line + "\n" for line in content.split("\n")]
13
- })
14
-
 
 
15
  # Clean up outputs/execution_count for markdown
16
  if cell_type == "markdown":
17
  del cells[-1]["execution_count"]
@@ -20,11 +23,7 @@ def create_notebook(filename: str, cells_content: list):
20
  notebook = {
21
  "cells": cells,
22
  "metadata": {
23
- "kernelspec": {
24
- "display_name": "Python 3",
25
- "language": "python",
26
- "name": "python3"
27
- },
28
  "language_info": {
29
  "codemirror_mode": {"name": "ipython", "version": 3},
30
  "file_extension": ".py",
@@ -32,42 +31,116 @@ def create_notebook(filename: str, cells_content: list):
32
  "name": "python",
33
  "nbconvert_exporter": "python",
34
  "pygments_lexer": "ipython3",
35
- "version": "3.11.0"
36
- }
37
  },
38
  "nbformat": 4,
39
- "nbformat_minor": 4
40
  }
41
-
42
  Path("notebooks").mkdir(parents=True, exist_ok=True)
43
  with open(f"notebooks/{filename}", "w") as f:
44
  json.dump(notebook, f, indent=2)
45
 
 
46
  def main():
47
  colab_cells = [
48
- ("# Google Colab Training Notebook\n\nThis notebook is intended to be run on Google Colab with a T4 GPU. It clones the repo, installs dependencies, and runs the training scripts.", "markdown"),
49
- ("!git clone https://github.com/Aryanmishra-dev/Multilingual-Absa.git\n%cd Multilingual-Absa\n!pip install .", "code"),
50
- ("# Mount Google Drive to save models and MLflow logs persistently\nfrom google.colab import drive\ndrive.mount('/content/drive')", "code"),
51
- ("# Create symlinks or copy data if needed\n# Assuming data is in the repo for now\n!mkdir -p /content/drive/MyDrive/ABSA_models", "code"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  ("# Prepare dataset\n!PYTHONPATH=src python -m absa.data.hf_dataset", "code"),
53
- ("# Run Aspect Extraction Training\n!PYTHONPATH=src python -m absa.models.train_aspect_extraction", "code"),
54
- ("# Run Sentiment Classification Training\n!PYTHONPATH=src python -m absa.models.train_sentiment", "code"),
 
 
 
 
 
 
55
  ("# Run Baseline as well\n!PYTHONPATH=src python -m absa.models.baseline", "code"),
56
- ("# Cross-lingual Evaluation\n!PYTHONPATH=src python -m absa.evaluation.cross_lingual_eval", "code"),
57
- ("# Copy models back to Drive\n!cp -r models/* /content/drive/MyDrive/ABSA_models/\n!cp -r mlflow /content/drive/MyDrive/ABSA_models/", "code")
 
 
 
 
 
 
 
 
58
  ]
59
-
60
  comparison_cells = [
61
- ("# Model Comparison\n\nThis notebook connects to the MLflow tracking server and compares the results of our models.", "markdown"),
62
- ("import mlflow\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nimport json\n\nmlflow.set_tracking_uri('sqlite:///mlflow/mlflow.db')", "code"),
63
- ("# Load all runs\nexperiment = mlflow.get_experiment_by_name('multilingual-absa')\ndf = mlflow.search_runs(experiment_ids=[experiment.experiment_id])\ndisplay(df.head())", "code"),
64
- ("# Bar chart: macro-F1 comparison\nmetrics = df[['tags.mlflow.runName', 'metrics.eval_macro_f1', 'metrics.test_f1', 'metrics.test_macro_f1', 'metrics.hindi_zero_shot_macro_f1']].fillna(0)\nmetrics['Best F1'] = metrics[['metrics.eval_macro_f1', 'metrics.test_f1', 'metrics.test_macro_f1']].max(axis=1)\n\nplt.figure(figsize=(10, 6))\nsns.barplot(data=metrics, x='tags.mlflow.runName', y='Best F1')\nplt.title('Model Comparison by Macro-F1 / Span-F1')\nplt.xticks(rotation=45)\nplt.show()", "code"),
65
- ("# Load confusion matrix for best sentiment classifier\n# Note: Assuming the confusion_matrix.json artifact was downloaded or parsed.\nprint('Confusion Matrix (Placeholder for artifact loading)')", "code"),
66
- ("# 5 Example Predictions\nprint('Example 1: The food was great but service was slow.')\nprint('Example 2: El sistema operativo es muy estable.')\nprint('... (Load pipeline and infer here)')", "code")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  ]
68
-
69
- create_notebook("03_train_colab.ipynb", colab_cells)
70
  create_notebook("03_model_comparison.ipynb", comparison_cells)
71
 
72
- if __name__ == '__main__':
 
73
  main()
 
1
  import json
2
  from pathlib import Path
3
 
4
+
5
  def create_notebook(filename: str, cells_content: list):
6
  cells = []
7
  for content, cell_type in cells_content:
8
+ cells.append(
9
+ {
10
+ "cell_type": cell_type,
11
+ "metadata": {},
12
+ "execution_count": None if cell_type == "code" else None,
13
+ "outputs": [] if cell_type == "code" else None,
14
+ "source": [line + "\n" for line in content.split("\n")],
15
+ }
16
+ )
17
+
18
  # Clean up outputs/execution_count for markdown
19
  if cell_type == "markdown":
20
  del cells[-1]["execution_count"]
 
23
  notebook = {
24
  "cells": cells,
25
  "metadata": {
26
+ "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
 
 
 
 
27
  "language_info": {
28
  "codemirror_mode": {"name": "ipython", "version": 3},
29
  "file_extension": ".py",
 
31
  "name": "python",
32
  "nbconvert_exporter": "python",
33
  "pygments_lexer": "ipython3",
34
+ "version": "3.11.0",
35
+ },
36
  },
37
  "nbformat": 4,
38
+ "nbformat_minor": 4,
39
  }
40
+
41
  Path("notebooks").mkdir(parents=True, exist_ok=True)
42
  with open(f"notebooks/{filename}", "w") as f:
43
  json.dump(notebook, f, indent=2)
44
 
45
+
46
  def main():
47
  colab_cells = [
48
+ (
49
+ "# Google Colab Training Notebook\n\n"
50
+ "This notebook is intended to be run on Google Colab with a T4 GPU. "
51
+ "It clones the repo, installs dependencies, and runs the training scripts.",
52
+ "markdown",
53
+ ),
54
+ (
55
+ "!git clone https://github.com/Aryanmishra-dev/Multilingual-Absa.git\n"
56
+ "%cd Multilingual-Absa\n!pip install .",
57
+ "code",
58
+ ),
59
+ (
60
+ "# Mount Google Drive to save models and MLflow logs persistently\n"
61
+ "from google.colab import drive\ndrive.mount('/content/drive')",
62
+ "code",
63
+ ),
64
+ (
65
+ "# Create symlinks or copy data if needed\n"
66
+ "# Assuming data is in the repo for now\n"
67
+ "!mkdir -p /content/drive/MyDrive/ABSA_models",
68
+ "code",
69
+ ),
70
  ("# Prepare dataset\n!PYTHONPATH=src python -m absa.data.hf_dataset", "code"),
71
+ (
72
+ "# Run Aspect Extraction Training\n!PYTHONPATH=src python -m absa.models.train_aspect_extraction",
73
+ "code",
74
+ ),
75
+ (
76
+ "# Run Sentiment Classification Training\n!PYTHONPATH=src python -m absa.models.train_sentiment",
77
+ "code",
78
+ ),
79
  ("# Run Baseline as well\n!PYTHONPATH=src python -m absa.models.baseline", "code"),
80
+ (
81
+ "# Cross-lingual Evaluation\n!PYTHONPATH=src python -m absa.evaluation.cross_lingual_eval",
82
+ "code",
83
+ ),
84
+ (
85
+ "# Copy models back to Drive\n"
86
+ "!cp -r models/* /content/drive/MyDrive/ABSA_models/\n"
87
+ "!cp -r mlflow /content/drive/MyDrive/ABSA_models/",
88
+ "code",
89
+ ),
90
  ]
91
+
92
  comparison_cells = [
93
+ (
94
+ "# Model Comparison\n\n"
95
+ "This notebook connects to the MLflow tracking server and compares the "
96
+ "results of our models.",
97
+ "markdown",
98
+ ),
99
+ (
100
+ "import mlflow\nimport pandas as pd\nimport matplotlib.pyplot as plt\n"
101
+ "import seaborn as sns\nimport json\n\n"
102
+ "mlflow.set_tracking_uri('sqlite:///mlflow/mlflow.db')",
103
+ "code",
104
+ ),
105
+ (
106
+ "# Load all runs\n"
107
+ "experiment = mlflow.get_experiment_by_name('multilingual-absa')\n"
108
+ "df = mlflow.search_runs(experiment_ids=[experiment.experiment_id])\n"
109
+ "display(df.head())",
110
+ "code",
111
+ ),
112
+ (
113
+ "# Bar chart: macro-F1 comparison\n"
114
+ "metrics = df[['tags.mlflow.runName', 'metrics.eval_macro_f1', "
115
+ "'metrics.test_f1', 'metrics.test_macro_f1', "
116
+ "'metrics.hindi_zero_shot_macro_f1']].fillna(0)\n"
117
+ "metrics['Best F1'] = metrics[['metrics.eval_macro_f1', "
118
+ "'metrics.test_f1', 'metrics.test_macro_f1']].max(axis=1)\n\n"
119
+ "plt.figure(figsize=(10, 6))\n"
120
+ "sns.barplot(data=metrics, x='tags.mlflow.runName', y='Best F1')\n"
121
+ "plt.title('Model Comparison by Macro-F1 / Span-F1')\n"
122
+ "plt.xticks(rotation=45)\nplt.show()",
123
+ "code",
124
+ ),
125
+ (
126
+ "# Load confusion matrix for best sentiment classifier\n"
127
+ "# Note: Assuming the confusion_matrix.json artifact was downloaded "
128
+ "or parsed.\n"
129
+ "print('Confusion Matrix (Placeholder for artifact loading)')",
130
+ "code",
131
+ ),
132
+ (
133
+ "# 5 Example Predictions\n"
134
+ "print('Example 1: The food was great but service was slow.')\n"
135
+ "print('Example 2: El sistema operativo es muy estable.')\n"
136
+ "print('... (Load pipeline and infer here)')",
137
+ "code",
138
+ ),
139
  ]
140
+
141
+ create_notebook("02_train_colab.ipynb", colab_cells)
142
  create_notebook("03_model_comparison.ipynb", comparison_cells)
143
 
144
+
145
+ if __name__ == "__main__":
146
  main()
scripts/init_db.py CHANGED
@@ -1,11 +1,13 @@
1
  import os
2
- from sqlalchemy import create_engine
 
3
  from dotenv import load_dotenv
 
4
 
5
- import sys
6
  sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7
 
8
- from api.schemas.db_models import Base
 
9
 
10
  def init_db():
11
  load_dotenv()
@@ -13,10 +15,11 @@ def init_db():
13
  if not database_url:
14
  print("DATABASE_URL not set in .env")
15
  return
16
-
17
  engine = create_engine(database_url)
18
  Base.metadata.create_all(bind=engine)
19
  print("Database tables created successfully.")
20
 
 
21
  if __name__ == "__main__":
22
  init_db()
 
1
  import os
2
+ import sys
3
+
4
  from dotenv import load_dotenv
5
+ from sqlalchemy import create_engine
6
 
 
7
  sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8
 
9
+ from api.models.db_models import Base
10
+
11
 
12
  def init_db():
13
  load_dotenv()
 
15
  if not database_url:
16
  print("DATABASE_URL not set in .env")
17
  return
18
+
19
  engine = create_engine(database_url)
20
  Base.metadata.create_all(bind=engine)
21
  print("Database tables created successfully.")
22
 
23
+
24
  if __name__ == "__main__":
25
  init_db()
scripts/upload_models.py CHANGED
@@ -1,6 +1,8 @@
1
  import os
 
2
  from huggingface_hub import HfApi
3
 
 
4
  def main():
5
  api = HfApi()
6
  repo_name = "multilingual-absa"
@@ -16,11 +18,10 @@ def main():
16
 
17
  print("Uploading models/onnx/ folder...")
18
  api.upload_folder(
19
- folder_path="models/onnx/",
20
- repo_id=repo_id,
21
- commit_message="Upload INT8 ONNX models for Multilingual ABSA"
22
  )
23
  print("Upload complete!")
24
 
 
25
  if __name__ == "__main__":
26
  main()
 
1
  import os
2
+
3
  from huggingface_hub import HfApi
4
 
5
+
6
  def main():
7
  api = HfApi()
8
  repo_name = "multilingual-absa"
 
18
 
19
  print("Uploading models/onnx/ folder...")
20
  api.upload_folder(
21
+ folder_path="models/onnx/", repo_id=repo_id, commit_message="Upload INT8 ONNX models for Multilingual ABSA"
 
 
22
  )
23
  print("Upload complete!")
24
 
25
+
26
  if __name__ == "__main__":
27
  main()
src/absa/data/augmentation.py CHANGED
@@ -5,11 +5,12 @@ Targets minority classes in Hindi data (negative and conflict).
5
 
6
  import json
7
  import random
8
- from pathlib import Path
9
  from collections import Counter
10
- from transformers import MarianMTModel, MarianTokenizer
11
- import torch
12
  import mlflow
 
 
13
 
14
  random.seed(42)
15
 
@@ -33,12 +34,8 @@ class BackTranslator:
33
  return [tokenizer.decode(t, skip_special_tokens=True) for t in translated]
34
 
35
  def back_translate(self, text):
36
- en_translation = self.translate([text], self.hi2en_model, self.hi2en_tokenizer)[
37
- 0
38
- ]
39
- back_to_hi = self.translate(
40
- [en_translation], self.en2hi_model, self.en2hi_tokenizer
41
- )[0]
42
  return back_to_hi
43
 
44
 
 
5
 
6
  import json
7
  import random
 
8
  from collections import Counter
9
+ from pathlib import Path
10
+
11
  import mlflow
12
+ import torch
13
+ from transformers import MarianMTModel, MarianTokenizer
14
 
15
  random.seed(42)
16
 
 
34
  return [tokenizer.decode(t, skip_special_tokens=True) for t in translated]
35
 
36
  def back_translate(self, text):
37
+ en_translation = self.translate([text], self.hi2en_model, self.hi2en_tokenizer)[0]
38
+ back_to_hi = self.translate([en_translation], self.en2hi_model, self.en2hi_tokenizer)[0]
 
 
 
 
39
  return back_to_hi
40
 
41
 
src/absa/data/bio_tagger.py CHANGED
@@ -1,5 +1,5 @@
1
  import re
2
- from typing import List, Dict, Any, Tuple
3
 
4
 
5
  def tokenize(text: str) -> List[Tuple[str, int, int]]:
@@ -20,9 +20,7 @@ def tokenize(text: str) -> List[Tuple[str, int, int]]:
20
  return tokens
21
 
22
 
23
- def convert_to_bio(
24
- text: str, aspect_terms: List[Dict[str, Any]]
25
- ) -> List[Dict[str, Any]]:
26
  """
27
  Converts text and aspect spans to BIO tagged tokens.
28
 
@@ -58,11 +56,7 @@ def convert_to_bio(
58
  if not (t_end <= a_start or t_start >= a_end):
59
  # There is overlap
60
  # If this token overlaps with the start of the aspect
61
- if t_start <= a_start or (
62
- len(bio_tags) > 0
63
- and bio_tags[-1]["label"] == "O"
64
- and t_start > a_start
65
- ):
66
  label = "B-ASP"
67
  else:
68
  # Check if previous tag was B-ASP or I-ASP for the *same* aspect
 
1
  import re
2
+ from typing import Any, Dict, List, Tuple
3
 
4
 
5
  def tokenize(text: str) -> List[Tuple[str, int, int]]:
 
20
  return tokens
21
 
22
 
23
+ def convert_to_bio(text: str, aspect_terms: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
 
 
24
  """
25
  Converts text and aspect spans to BIO tagged tokens.
26
 
 
56
  if not (t_end <= a_start or t_start >= a_end):
57
  # There is overlap
58
  # If this token overlaps with the start of the aspect
59
+ if t_start <= a_start or (len(bio_tags) > 0 and bio_tags[-1]["label"] == "O" and t_start > a_start):
 
 
 
 
60
  label = "B-ASP"
61
  else:
62
  # Check if previous tag was B-ASP or I-ASP for the *same* aspect
src/absa/data/dataset.py CHANGED
@@ -1,9 +1,11 @@
1
  import json
2
- from datasets import load_from_disk
3
  from collections import defaultdict
4
- from absa.utils.config import RAW_DIR, SEMEVAL_TRAIN_PATH, SEMEVAL_TEST_PATH
5
- from absa.data.preprocess import clean
 
6
  from absa.data.lang_detect import detect_language
 
 
7
 
8
 
9
  def process_semeval():
@@ -13,8 +15,8 @@ def process_semeval():
13
  rest_data = load_from_disk(str(rest_path))
14
  lap_data = load_from_disk(str(lap_path))
15
 
16
- train_samples = defaultdict(list)
17
- test_samples = defaultdict(list)
18
 
19
  for ds_name, ds, source_name in [
20
  ("train", rest_data["train"], "restaurants"),
@@ -37,7 +39,7 @@ def process_semeval():
37
  (SEMEVAL_TEST_PATH, test_samples),
38
  ]:
39
  total = 0
40
- lang_counts = defaultdict(int)
41
  with open(path, "w", encoding="utf-8") as f:
42
  for (text, source), aspects in data_dict.items():
43
  lang = detect_language(text)
 
1
  import json
 
2
  from collections import defaultdict
3
+
4
+ from datasets import load_from_disk
5
+
6
  from absa.data.lang_detect import detect_language
7
+ from absa.data.preprocess import clean
8
+ from absa.utils.config import RAW_DIR, SEMEVAL_TEST_PATH, SEMEVAL_TRAIN_PATH
9
 
10
 
11
  def process_semeval():
 
15
  rest_data = load_from_disk(str(rest_path))
16
  lap_data = load_from_disk(str(lap_path))
17
 
18
+ train_samples: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
19
+ test_samples: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
20
 
21
  for ds_name, ds, source_name in [
22
  ("train", rest_data["train"], "restaurants"),
 
39
  (SEMEVAL_TEST_PATH, test_samples),
40
  ]:
41
  total = 0
42
+ lang_counts: defaultdict[str, int] = defaultdict(int)
43
  with open(path, "w", encoding="utf-8") as f:
44
  for (text, source), aspects in data_dict.items():
45
  lang = detect_language(text)
src/absa/data/hf_dataset.py CHANGED
@@ -1,11 +1,13 @@
1
  import json
 
 
 
2
  import numpy as np
3
  import pandas as pd
4
- from pathlib import Path
5
- from typing import List, Dict, Any
6
  from datasets import Dataset, DatasetDict
7
- from transformers import AutoTokenizer
8
  from sklearn.model_selection import train_test_split
 
 
9
  from absa.data.bio_tagger import convert_to_bio
10
 
11
  np.random.seed(42)
@@ -147,12 +149,8 @@ def main():
147
  cls_df = pd.DataFrame(cls_data)
148
 
149
  # Stratified split 80/10/10 based on label
150
- train_cls, temp_cls = train_test_split(
151
- cls_df, test_size=0.2, random_state=42, stratify=cls_df["label"]
152
- )
153
- val_cls, test_cls = train_test_split(
154
- temp_cls, test_size=0.5, random_state=42, stratify=temp_cls["label"]
155
- )
156
 
157
  def tokenize_cls(examples):
158
  # Format: [CLS] text [SEP] aspect_term [SEP]
@@ -172,9 +170,7 @@ def main():
172
  }
173
  )
174
 
175
- tokenized_cls = cls_dataset.map(
176
- tokenize_cls, batched=True, remove_columns=["text", "aspect_term", "id"]
177
- )
178
  tokenized_cls.save_to_disk(str(output_dir / "absa_cls_dataset"))
179
  print(f"CLS Dataset saved to {output_dir / 'absa_cls_dataset'}")
180
 
 
1
  import json
2
+ from pathlib import Path
3
+ from typing import Any, Dict, List
4
+
5
  import numpy as np
6
  import pandas as pd
 
 
7
  from datasets import Dataset, DatasetDict
 
8
  from sklearn.model_selection import train_test_split
9
+ from transformers import AutoTokenizer
10
+
11
  from absa.data.bio_tagger import convert_to_bio
12
 
13
  np.random.seed(42)
 
149
  cls_df = pd.DataFrame(cls_data)
150
 
151
  # Stratified split 80/10/10 based on label
152
+ train_cls, temp_cls = train_test_split(cls_df, test_size=0.2, random_state=42, stratify=cls_df["label"])
153
+ val_cls, test_cls = train_test_split(temp_cls, test_size=0.5, random_state=42, stratify=temp_cls["label"])
 
 
 
 
154
 
155
  def tokenize_cls(examples):
156
  # Format: [CLS] text [SEP] aspect_term [SEP]
 
170
  }
171
  )
172
 
173
+ tokenized_cls = cls_dataset.map(tokenize_cls, batched=True, remove_columns=["text", "aspect_term", "id"])
 
 
174
  tokenized_cls.save_to_disk(str(output_dir / "absa_cls_dataset"))
175
  print(f"CLS Dataset saved to {output_dir / 'absa_cls_dataset'}")
176
 
src/absa/data/hindi_loader.py CHANGED
@@ -1,7 +1,8 @@
1
  import json
2
- from absa.utils.config import RAW_DIR, AMAZON_HINDI_PATH
3
- from absa.data.preprocess import clean
4
  from absa.data.lang_detect import detect_language
 
 
5
 
6
 
7
  def process_hindi():
@@ -15,9 +16,7 @@ def process_hindi():
15
  total = 0
16
  lang_counts = {"hi": 0, "hinglish": 0, "en": 0, "other": 0}
17
 
18
- with open(raw_path, "r", encoding="utf-8") as fin, open(
19
- AMAZON_HINDI_PATH, "w", encoding="utf-8"
20
- ) as fout:
21
  for line in fin:
22
  row = json.loads(line)
23
  text = row.get("INDIC REVIEW", row.get("text", ""))
 
1
  import json
2
+
 
3
  from absa.data.lang_detect import detect_language
4
+ from absa.data.preprocess import clean
5
+ from absa.utils.config import AMAZON_HINDI_PATH, RAW_DIR
6
 
7
 
8
  def process_hindi():
 
16
  total = 0
17
  lang_counts = {"hi": 0, "hinglish": 0, "en": 0, "other": 0}
18
 
19
+ with open(raw_path, "r", encoding="utf-8") as fin, open(AMAZON_HINDI_PATH, "w", encoding="utf-8") as fout:
 
 
20
  for line in fin:
21
  row = json.loads(line)
22
  text = row.get("INDIC REVIEW", row.get("text", ""))
src/absa/data/lang_detect.py CHANGED
@@ -1,5 +1,7 @@
1
  import re
 
2
  import fasttext
 
3
  from absa.utils.config import FASTTEXT_MODEL_PATH
4
 
5
  _model = None
 
1
  import re
2
+
3
  import fasttext
4
+
5
  from absa.utils.config import FASTTEXT_MODEL_PATH
6
 
7
  _model = None
src/absa/data/preprocess.py CHANGED
@@ -1,5 +1,6 @@
1
  import re
2
  import unicodedata
 
3
  from absa.data.transliterate import transliterate
4
 
5
 
 
1
  import re
2
  import unicodedata
3
+
4
  from absa.data.transliterate import transliterate
5
 
6
 
src/absa/data/transliterate.py CHANGED
@@ -1,6 +1,6 @@
1
  import logging
2
- import unicodedata
3
  import re
 
4
 
5
  logger = logging.getLogger(__name__)
6
 
@@ -10,9 +10,7 @@ try:
10
  HAS_INDIC_NLP = True
11
  except ImportError:
12
  HAS_INDIC_NLP = False
13
- logger.warning(
14
- "indic-nlp-library not found. Transliteration will fallback to basic unicode handling."
15
- )
16
 
17
 
18
  def transliterate(text: str, src_lang: str) -> str:
@@ -30,11 +28,7 @@ def transliterate(text: str, src_lang: str) -> str:
30
  roman_text = text
31
  else:
32
  # Fallback to basic unicode normalization
33
- roman_text = (
34
- unicodedata.normalize("NFKD", text)
35
- .encode("ascii", "ignore")
36
- .decode("utf-8")
37
- )
38
 
39
  if not roman_text:
40
  roman_text = text
 
1
  import logging
 
2
  import re
3
+ import unicodedata
4
 
5
  logger = logging.getLogger(__name__)
6
 
 
10
  HAS_INDIC_NLP = True
11
  except ImportError:
12
  HAS_INDIC_NLP = False
13
+ logger.warning("indic-nlp-library not found. Transliteration will fallback to basic unicode handling.")
 
 
14
 
15
 
16
  def transliterate(text: str, src_lang: str) -> str:
 
28
  roman_text = text
29
  else:
30
  # Fallback to basic unicode normalization
31
+ roman_text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("utf-8")
 
 
 
 
32
 
33
  if not roman_text:
34
  roman_text = text
src/absa/evaluation/benchmark_latency.py CHANGED
@@ -4,9 +4,10 @@ Script to benchmark latency for PyTorch, ONNX, and ONNX INT8 models on CPU.
4
 
5
  import time
6
  from pathlib import Path
 
7
  import numpy as np
8
  import torch
9
- from transformers import AutoTokenizer, AutoModelForSequenceClassification
10
 
11
  try:
12
  from optimum.onnxruntime import ORTModelForSequenceClassification
@@ -30,9 +31,7 @@ def benchmark_model(model, tokenizer, texts, model_type="pytorch"):
30
 
31
  print(f"Benchmarking {model_type}...")
32
  for text in texts:
33
- inputs = tokenizer(
34
- [text], return_tensors="pt", padding=True, truncation=True, max_length=128
35
- )
36
 
37
  start_time = time.perf_counter()
38
  if model_type == "pytorch":
@@ -82,12 +81,8 @@ def main():
82
  # 2. ONNX CPU
83
  if onnx_dir.exists():
84
  print("Loading ONNX model...")
85
- onnx_model = ORTModelForSequenceClassification.from_pretrained(
86
- str(onnx_dir)
87
- )
88
- mean_onnx, p95_onnx, tput_onnx = benchmark_model(
89
- onnx_model, tokenizer, texts, "onnx"
90
- )
91
  results["ONNX (CPU)"] = {
92
  "mean_ms": mean_onnx,
93
  "p95_ms": p95_onnx,
@@ -97,12 +92,8 @@ def main():
97
  # 3. ONNX INT8 CPU
98
  if int8_dir.exists():
99
  print("Loading ONNX INT8 model...")
100
- int8_model = ORTModelForSequenceClassification.from_pretrained(
101
- str(int8_dir)
102
- )
103
- mean_int8, p95_int8, tput_int8 = benchmark_model(
104
- int8_model, tokenizer, texts, "onnx_int8"
105
- )
106
  results["ONNX INT8 (CPU)"] = {
107
  "mean_ms": mean_int8,
108
  "p95_ms": p95_int8,
@@ -110,26 +101,18 @@ def main():
110
  }
111
 
112
  print("\n--- Latency Benchmark Results ---")
113
- print(
114
- f"{'Model':<20} | {'Mean (ms)':<10} | {'P95 (ms)':<10} | {'Throughput (samples/s)':<25}"
115
- )
116
  print("-" * 75)
117
  for name, metrics in results.items():
118
- print(
119
- f"{name:<20} | {metrics['mean_ms']:<10.2f} | {metrics['p95_ms']:<10.2f} | {metrics['throughput']:<25.2f}"
120
- )
121
 
122
  # Target check
123
  if "ONNX INT8 (CPU)" in results:
124
  int8_p95 = results["ONNX INT8 (CPU)"]["p95_ms"]
125
  if int8_p95 < 300:
126
- print(
127
- f"\nSUCCESS: ONNX INT8 P95 latency is {int8_p95:.2f}ms, which is < 300ms target."
128
- )
129
  else:
130
- print(
131
- f"\nWARNING: ONNX INT8 P95 latency is {int8_p95:.2f}ms, which is > 300ms target."
132
- )
133
 
134
  mlflow.set_tracking_uri("sqlite:///mlflow.db")
135
  mlflow.set_experiment("latency-benchmark")
 
4
 
5
  import time
6
  from pathlib import Path
7
+
8
  import numpy as np
9
  import torch
10
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
11
 
12
  try:
13
  from optimum.onnxruntime import ORTModelForSequenceClassification
 
31
 
32
  print(f"Benchmarking {model_type}...")
33
  for text in texts:
34
+ inputs = tokenizer([text], return_tensors="pt", padding=True, truncation=True, max_length=128)
 
 
35
 
36
  start_time = time.perf_counter()
37
  if model_type == "pytorch":
 
81
  # 2. ONNX CPU
82
  if onnx_dir.exists():
83
  print("Loading ONNX model...")
84
+ onnx_model = ORTModelForSequenceClassification.from_pretrained(str(onnx_dir))
85
+ mean_onnx, p95_onnx, tput_onnx = benchmark_model(onnx_model, tokenizer, texts, "onnx")
 
 
 
 
86
  results["ONNX (CPU)"] = {
87
  "mean_ms": mean_onnx,
88
  "p95_ms": p95_onnx,
 
92
  # 3. ONNX INT8 CPU
93
  if int8_dir.exists():
94
  print("Loading ONNX INT8 model...")
95
+ int8_model = ORTModelForSequenceClassification.from_pretrained(str(int8_dir))
96
+ mean_int8, p95_int8, tput_int8 = benchmark_model(int8_model, tokenizer, texts, "onnx_int8")
 
 
 
 
97
  results["ONNX INT8 (CPU)"] = {
98
  "mean_ms": mean_int8,
99
  "p95_ms": p95_int8,
 
101
  }
102
 
103
  print("\n--- Latency Benchmark Results ---")
104
+ print(f"{'Model':<20} | {'Mean (ms)':<10} | {'P95 (ms)':<10} | {'Throughput (samples/s)':<25}")
 
 
105
  print("-" * 75)
106
  for name, metrics in results.items():
107
+ print(f"{name:<20} | {metrics['mean_ms']:<10.2f} | {metrics['p95_ms']:<10.2f} | {metrics['throughput']:<25.2f}")
 
 
108
 
109
  # Target check
110
  if "ONNX INT8 (CPU)" in results:
111
  int8_p95 = results["ONNX INT8 (CPU)"]["p95_ms"]
112
  if int8_p95 < 300:
113
+ print(f"\nSUCCESS: ONNX INT8 P95 latency is {int8_p95:.2f}ms, which is < 300ms target.")
 
 
114
  else:
115
+ print(f"\nWARNING: ONNX INT8 P95 latency is {int8_p95:.2f}ms, which is > 300ms target.")
 
 
116
 
117
  mlflow.set_tracking_uri("sqlite:///mlflow.db")
118
  mlflow.set_experiment("latency-benchmark")
src/absa/evaluation/cross_lingual_eval.py CHANGED
@@ -1,14 +1,15 @@
1
  import json
2
- import torch
3
  from pathlib import Path
 
 
 
 
4
  from transformers import (
5
- AutoTokenizer,
6
- AutoModelForTokenClassification,
7
  AutoModelForSequenceClassification,
 
 
8
  pipeline,
9
  )
10
- from sklearn.metrics import f1_score
11
- import mlflow
12
 
13
  from absa.training.mlflow_utils import setup_mlflow
14
 
@@ -30,20 +31,14 @@ def main():
30
  sentiment_model_path = Path("models/sentiment/best")
31
 
32
  if not aspect_model_path.exists() or not sentiment_model_path.exists():
33
- print(
34
- "Models not found locally. Skipping cross-lingual evaluation until models are trained."
35
- )
36
  return
37
 
38
  print("Loading models...")
39
  tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
40
 
41
- aspect_model = AutoModelForTokenClassification.from_pretrained(
42
- str(aspect_model_path)
43
- )
44
- sentiment_model = AutoModelForSequenceClassification.from_pretrained(
45
- str(sentiment_model_path)
46
- )
47
 
48
  device = 0 if torch.cuda.is_available() else -1
49
 
@@ -79,9 +74,7 @@ def main():
79
  true_labels.append(sentiment_map[true_polarity])
80
 
81
  # Inference Sentiment
82
- inputs = tokenizer(
83
- text, term, return_tensors="pt", truncation=True, max_length=128
84
- )
85
  if device == 0:
86
  inputs = {k: v.to("cuda") for k, v in inputs.items()}
87
  sentiment_model.to("cuda")
@@ -92,11 +85,7 @@ def main():
92
 
93
  pred_labels.append(pred_idx)
94
 
95
- hindi_macro_f1 = (
96
- f1_score(true_labels, pred_labels, average="macro")
97
- if len(true_labels) > 0
98
- else 0.0
99
- )
100
  print(f"Hindi Zero-Shot Macro-F1: {hindi_macro_f1}")
101
 
102
  # We retrieve the best English test score from MLflow
 
1
  import json
 
2
  from pathlib import Path
3
+
4
+ import mlflow
5
+ import torch
6
+ from sklearn.metrics import f1_score
7
  from transformers import (
 
 
8
  AutoModelForSequenceClassification,
9
+ AutoModelForTokenClassification,
10
+ AutoTokenizer,
11
  pipeline,
12
  )
 
 
13
 
14
  from absa.training.mlflow_utils import setup_mlflow
15
 
 
31
  sentiment_model_path = Path("models/sentiment/best")
32
 
33
  if not aspect_model_path.exists() or not sentiment_model_path.exists():
34
+ print("Models not found locally. Skipping cross-lingual evaluation until models are trained.")
 
 
35
  return
36
 
37
  print("Loading models...")
38
  tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
39
 
40
+ aspect_model = AutoModelForTokenClassification.from_pretrained(str(aspect_model_path))
41
+ sentiment_model = AutoModelForSequenceClassification.from_pretrained(str(sentiment_model_path))
 
 
 
 
42
 
43
  device = 0 if torch.cuda.is_available() else -1
44
 
 
74
  true_labels.append(sentiment_map[true_polarity])
75
 
76
  # Inference Sentiment
77
+ inputs = tokenizer(text, term, return_tensors="pt", truncation=True, max_length=128)
 
 
78
  if device == 0:
79
  inputs = {k: v.to("cuda") for k, v in inputs.items()}
80
  sentiment_model.to("cuda")
 
85
 
86
  pred_labels.append(pred_idx)
87
 
88
+ hindi_macro_f1 = f1_score(true_labels, pred_labels, average="macro") if len(true_labels) > 0 else 0.0
 
 
 
 
89
  print(f"Hindi Zero-Shot Macro-F1: {hindi_macro_f1}")
90
 
91
  # We retrieve the best English test score from MLflow
src/absa/evaluation/final_eval.py CHANGED
@@ -1,5 +1,5 @@
1
- import os
2
  import json
 
3
 
4
 
5
  def run_evaluation():
 
 
1
  import json
2
+ import os
3
 
4
 
5
  def run_evaluation():
src/absa/models/baseline.py CHANGED
@@ -1,13 +1,15 @@
1
  import json
2
- import joblib
3
  from pathlib import Path
4
  from typing import List
 
 
 
5
  import pandas as pd
6
  from sklearn.feature_extraction.text import TfidfVectorizer
7
  from sklearn.linear_model import LogisticRegression
8
- from sklearn.metrics import f1_score, confusion_matrix, classification_report
9
  from sklearn.model_selection import train_test_split
10
- import mlflow
11
  from absa.training.mlflow_utils import log_training_run
12
 
13
 
@@ -60,47 +62,37 @@ def main():
60
  cls_df = extract_sentence_sentiment(train_df_raw)
61
 
62
  # Exact same split logic as hf_dataset.py
63
- train_cls, temp_cls = train_test_split(
64
- cls_df, test_size=0.2, random_state=42, stratify=cls_df["label"]
65
- )
66
- val_cls, test_cls = train_test_split(
67
- temp_cls, test_size=0.5, random_state=42, stratify=temp_cls["label"]
68
- )
69
-
70
- X_train = train_cls["text"].values
71
  y_train = train_cls["label"].values
72
 
73
- X_test = test_cls["text"].values
74
  y_test = test_cls["label"].values
75
 
76
- print(f"Training on {len(X_train)} samples, testing on {len(X_test)} samples.")
77
 
78
  # Baseline Model Pipeline
79
  vectorizer = TfidfVectorizer(ngram_range=(1, 2), max_features=10000)
80
- classifier = LogisticRegression(
81
- max_iter=1000, class_weight="balanced", random_state=42
82
- )
83
 
84
  # Train
85
  print("Training TF-IDF + Logistic Regression...")
86
- X_train_vec = vectorizer.fit_transform(X_train)
87
- classifier.fit(X_train_vec, y_train)
88
 
89
  # Evaluate
90
  print("Evaluating...")
91
- X_test_vec = vectorizer.transform(X_test)
92
- y_pred = classifier.predict(X_test_vec)
93
 
94
  # Metrics
95
  macro_f1 = f1_score(y_test, y_pred, average="macro")
96
  per_class_f1 = f1_score(y_test, y_pred, average=None)
97
  conf_matrix = confusion_matrix(y_test, y_pred)
98
 
99
- print(
100
- classification_report(
101
- y_test, y_pred, target_names=["positive", "negative", "neutral", "conflict"]
102
- )
103
- )
104
 
105
  # Format metrics for MLflow
106
  metrics = {
 
1
  import json
 
2
  from pathlib import Path
3
  from typing import List
4
+
5
+ import joblib
6
+ import mlflow
7
  import pandas as pd
8
  from sklearn.feature_extraction.text import TfidfVectorizer
9
  from sklearn.linear_model import LogisticRegression
10
+ from sklearn.metrics import classification_report, confusion_matrix, f1_score
11
  from sklearn.model_selection import train_test_split
12
+
13
  from absa.training.mlflow_utils import log_training_run
14
 
15
 
 
62
  cls_df = extract_sentence_sentiment(train_df_raw)
63
 
64
  # Exact same split logic as hf_dataset.py
65
+ train_cls, temp_cls = train_test_split(cls_df, test_size=0.2, random_state=42, stratify=cls_df["label"])
66
+ val_cls, test_cls = train_test_split(temp_cls, test_size=0.5, random_state=42, stratify=temp_cls["label"])
67
+
68
+ x_train = train_cls["text"].values
 
 
 
 
69
  y_train = train_cls["label"].values
70
 
71
+ x_test = test_cls["text"].values
72
  y_test = test_cls["label"].values
73
 
74
+ print(f"Training on {len(x_train)} samples, testing on {len(x_test)} samples.")
75
 
76
  # Baseline Model Pipeline
77
  vectorizer = TfidfVectorizer(ngram_range=(1, 2), max_features=10000)
78
+ classifier = LogisticRegression(max_iter=1000, class_weight="balanced", random_state=42)
 
 
79
 
80
  # Train
81
  print("Training TF-IDF + Logistic Regression...")
82
+ x_train_vec = vectorizer.fit_transform(x_train)
83
+ classifier.fit(x_train_vec, y_train)
84
 
85
  # Evaluate
86
  print("Evaluating...")
87
+ x_test_vec = vectorizer.transform(x_test)
88
+ y_pred = classifier.predict(x_test_vec)
89
 
90
  # Metrics
91
  macro_f1 = f1_score(y_test, y_pred, average="macro")
92
  per_class_f1 = f1_score(y_test, y_pred, average=None)
93
  conf_matrix = confusion_matrix(y_test, y_pred)
94
 
95
+ print(classification_report(y_test, y_pred, target_names=["positive", "negative", "neutral", "conflict"]))
 
 
 
 
96
 
97
  # Format metrics for MLflow
98
  metrics = {
src/absa/models/export_onnx.py CHANGED
@@ -7,8 +7,8 @@ from pathlib import Path
7
 
8
  try:
9
  from optimum.onnxruntime import (
10
- ORTModelForTokenClassification,
11
  ORTModelForSequenceClassification,
 
12
  ORTQuantizer,
13
  )
14
  from optimum.onnxruntime.configuration import AutoQuantizationConfig
@@ -19,9 +19,7 @@ except ImportError:
19
  print("Warning: optimum library not installed. Models will not be exported.")
20
 
21
 
22
- def export_and_quantize(
23
- model_type: str, source_dir: Path, export_dir: Path, quantize_dir: Path
24
- ):
25
  print(f"Exporting {model_type} model from {source_dir} to {export_dir}")
26
 
27
  if not source_dir.exists():
@@ -35,13 +33,9 @@ def export_and_quantize(
35
  # when `export=True` is passed for HF models, it sets dynamic sequence lengths automatically.
36
 
37
  if model_type == "token_classification":
38
- model = ORTModelForTokenClassification.from_pretrained(
39
- str(source_dir), export=True
40
- )
41
  elif model_type == "sequence_classification":
42
- model = ORTModelForSequenceClassification.from_pretrained(
43
- str(source_dir), export=True
44
- )
45
  else:
46
  raise ValueError(f"Unknown model_type: {model_type}")
47
 
 
7
 
8
  try:
9
  from optimum.onnxruntime import (
 
10
  ORTModelForSequenceClassification,
11
+ ORTModelForTokenClassification,
12
  ORTQuantizer,
13
  )
14
  from optimum.onnxruntime.configuration import AutoQuantizationConfig
 
19
  print("Warning: optimum library not installed. Models will not be exported.")
20
 
21
 
22
+ def export_and_quantize(model_type: str, source_dir: Path, export_dir: Path, quantize_dir: Path):
 
 
23
  print(f"Exporting {model_type} model from {source_dir} to {export_dir}")
24
 
25
  if not source_dir.exists():
 
33
  # when `export=True` is passed for HF models, it sets dynamic sequence lengths automatically.
34
 
35
  if model_type == "token_classification":
36
+ model = ORTModelForTokenClassification.from_pretrained(str(source_dir), export=True)
 
 
37
  elif model_type == "sequence_classification":
38
+ model = ORTModelForSequenceClassification.from_pretrained(str(source_dir), export=True)
 
 
39
  else:
40
  raise ValueError(f"Unknown model_type: {model_type}")
41
 
src/absa/models/train_aspect_extraction.py CHANGED
@@ -1,16 +1,17 @@
1
- import numpy as np
2
  from pathlib import Path
 
 
 
3
  from datasets import load_from_disk
 
4
  from transformers import (
5
  AutoModelForTokenClassification,
6
- TrainingArguments,
7
- Trainer,
8
- DataCollatorForTokenClassification,
9
  AutoTokenizer,
 
 
 
10
  set_seed,
11
  )
12
- from seqeval.metrics import f1_score as seqeval_f1_score
13
- import mlflow
14
 
15
  from absa.training.mlflow_utils import setup_mlflow
16
 
@@ -112,16 +113,13 @@ def main():
112
 
113
  # Log test metric manually since trainer.train() only automatically logs eval metrics
114
  # if report_to="mlflow" handles it, but test results we need to make sure are in the same run.
 
 
 
115
  with mlflow.start_run(
116
- run_id=(
117
- trainer.state.trial_params.get("mlflow_run_id")
118
- if trainer.state.trial_params
119
- else mlflow.active_run().info.run_id if mlflow.active_run() else None
120
- )
121
  ) as run:
122
- mlflow.log_metrics(
123
- {"test_f1": test_results["test_f1"], "test_loss": test_results["test_loss"]}
124
- )
125
  print(f"Logged test metrics to run {run.info.run_id}")
126
 
127
 
 
 
1
  from pathlib import Path
2
+
3
+ import mlflow
4
+ import numpy as np
5
  from datasets import load_from_disk
6
+ from seqeval.metrics import f1_score as seqeval_f1_score
7
  from transformers import (
8
  AutoModelForTokenClassification,
 
 
 
9
  AutoTokenizer,
10
+ DataCollatorForTokenClassification,
11
+ Trainer,
12
+ TrainingArguments,
13
  set_seed,
14
  )
 
 
15
 
16
  from absa.training.mlflow_utils import setup_mlflow
17
 
 
113
 
114
  # Log test metric manually since trainer.train() only automatically logs eval metrics
115
  # if report_to="mlflow" handles it, but test results we need to make sure are in the same run.
116
+ active_run = mlflow.active_run()
117
+ fallback_run_id = active_run.info.run_id if active_run else None
118
+
119
  with mlflow.start_run(
120
+ run_id=(trainer.state.trial_params.get("mlflow_run_id") if trainer.state.trial_params else fallback_run_id)
 
 
 
 
121
  ) as run:
122
+ mlflow.log_metrics({"test_f1": test_results["test_f1"], "test_loss": test_results["test_loss"]})
 
 
123
  print(f"Logged test metrics to run {run.info.run_id}")
124
 
125
 
src/absa/models/train_joint_absa.py CHANGED
@@ -2,26 +2,27 @@
2
  Script for training a Joint ABSA model (token classification + sentiment classification).
3
  """
4
 
 
5
  from pathlib import Path
 
 
 
 
6
  import torch
7
  import torch.nn as nn
 
8
  from transformers import (
9
- XLMRobertaPreTrainedModel,
10
- XLMRobertaModel,
11
  AutoTokenizer,
12
- TrainingArguments,
13
  Trainer,
 
 
 
14
  set_seed,
15
  )
16
- import mlflow
17
- import numpy as np
18
- from sklearn.metrics import f1_score
19
  from transformers.modeling_outputs import (
20
- TokenClassifierOutput,
21
  SequenceClassifierOutput,
 
22
  )
23
- from dataclasses import dataclass
24
- from typing import Optional, Tuple
25
 
26
  set_seed(42)
27
 
@@ -66,9 +67,7 @@ class JointABSAModel(XLMRobertaPreTrainedModel):
66
  output_hidden_states=None,
67
  return_dict=None,
68
  ):
69
- return_dict = (
70
- return_dict if return_dict is not None else self.config.use_return_dict
71
- )
72
 
73
  outputs = self.roberta(
74
  input_ids,
@@ -140,12 +139,8 @@ class JointTrainer(Trainer):
140
  def compute_metrics(eval_pred) -> dict:
141
  # eval_pred.predictions is a tuple: (ner_logits, cls_logits)
142
  ner_logits, cls_logits = eval_pred.predictions
143
- eval_pred.label_ids[
144
- 0
145
- ] # assuming we package them or trainer passes first
146
- sentiment_labels = (
147
- eval_pred.label_ids[1] if isinstance(eval_pred.label_ids, tuple) else None
148
- )
149
 
150
  # Normally we would properly unpack the labels and calculate span F1 and macro F1
151
  # For demonstration, computing random metrics based on dummy labels if not provided
@@ -171,9 +166,7 @@ def main():
171
 
172
  print("Loading tokenizer and model...")
173
  AutoTokenizer.from_pretrained(model_name)
174
- JointABSAModel.from_pretrained(
175
- model_name, num_ner_labels=3, num_sentiment_labels=4
176
- )
177
 
178
  TrainingArguments(
179
  output_dir=str(output_dir),
@@ -197,9 +190,7 @@ def main():
197
 
198
  with mlflow.start_run():
199
  # NOTE: Dummy dataset loading code omitted, this script sets up the model and loss structure
200
- print(
201
- "Joint model defined and ready for training (data loading logic to be implemented)."
202
- )
203
 
204
  # Log joint_span_f1 and joint_macro_f1 placeholder for API compatibility
205
  mlflow.log_metric("joint_span_f1", 0.0)
 
2
  Script for training a Joint ABSA model (token classification + sentiment classification).
3
  """
4
 
5
+ from dataclasses import dataclass
6
  from pathlib import Path
7
+ from typing import Optional, Tuple
8
+
9
+ import mlflow
10
+ import numpy as np
11
  import torch
12
  import torch.nn as nn
13
+ from sklearn.metrics import f1_score
14
  from transformers import (
 
 
15
  AutoTokenizer,
 
16
  Trainer,
17
+ TrainingArguments,
18
+ XLMRobertaModel,
19
+ XLMRobertaPreTrainedModel,
20
  set_seed,
21
  )
 
 
 
22
  from transformers.modeling_outputs import (
 
23
  SequenceClassifierOutput,
24
+ TokenClassifierOutput,
25
  )
 
 
26
 
27
  set_seed(42)
28
 
 
67
  output_hidden_states=None,
68
  return_dict=None,
69
  ):
70
+ return_dict = return_dict if return_dict is not None else self.config.use_return_dict
 
 
71
 
72
  outputs = self.roberta(
73
  input_ids,
 
139
  def compute_metrics(eval_pred) -> dict:
140
  # eval_pred.predictions is a tuple: (ner_logits, cls_logits)
141
  ner_logits, cls_logits = eval_pred.predictions
142
+ eval_pred.label_ids[0] # assuming we package them or trainer passes first
143
+ sentiment_labels = eval_pred.label_ids[1] if isinstance(eval_pred.label_ids, tuple) else None
 
 
 
 
144
 
145
  # Normally we would properly unpack the labels and calculate span F1 and macro F1
146
  # For demonstration, computing random metrics based on dummy labels if not provided
 
166
 
167
  print("Loading tokenizer and model...")
168
  AutoTokenizer.from_pretrained(model_name)
169
+ JointABSAModel.from_pretrained(model_name, num_ner_labels=3, num_sentiment_labels=4)
 
 
170
 
171
  TrainingArguments(
172
  output_dir=str(output_dir),
 
190
 
191
  with mlflow.start_run():
192
  # NOTE: Dummy dataset loading code omitted, this script sets up the model and loss structure
193
+ print("Joint model defined and ready for training (data loading logic to be implemented).")
 
 
194
 
195
  # Log joint_span_f1 and joint_macro_f1 placeholder for API compatibility
196
  mlflow.log_metric("joint_span_f1", 0.0)