Spaces:
Runtime error
Runtime error
Aryan Mishra commited on
Commit ·
a00fee9
1
Parent(s): dbd4e8b
Add CI, typed ORM models, and packaging cleanup
Browse filesIntroduce 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
- .editorconfig +26 -0
- .gitattributes +2 -4
- .github/workflows/ci.yml +78 -0
- .gitignore +2 -0
- .pre-commit-config.yaml +33 -0
- CONTRIBUTING.md +89 -0
- LICENSE +21 -0
- Makefile +8 -5
- README.md +20 -16
- SECURITY.md +31 -0
- api/main.py +2 -2
- api/middleware/dependencies.py +3 -6
- api/models/__init__.py +0 -0
- api/models/db_models.py +55 -0
- api/py.typed +0 -0
- api/routes/predict.py +17 -23
- api/routes/results.py +9 -14
- api/schemas/db_models.py +0 -43
- api/schemas/schemas.py +2 -1
- api/services/lang_service.py +3 -2
- api/tasks/__init__.py +3 -4
- api/tasks/batch_tasks.py +9 -6
- docs/DEPLOYMENT.md +2 -2
- docs/TECH_STACK.md +10 -10
- frontend/py.typed +0 -0
- notebooks/{03_train_colab.ipynb → 02_train_colab.ipynb} +6 -6
- notebooks/{08_final_evaluation.ipynb → 05_final_evaluation.ipynb} +0 -0
- notebooks/README.md +16 -0
- pyproject.toml +6 -0
- scripts/README.md +13 -0
- scripts/download_data.py +12 -7
- scripts/drift_monitor.py +23 -22
- scripts/generate_notebooks.py +108 -35
- scripts/init_db.py +7 -4
- scripts/upload_models.py +4 -3
- src/absa/data/augmentation.py +6 -9
- src/absa/data/bio_tagger.py +3 -9
- src/absa/data/dataset.py +8 -6
- src/absa/data/hf_dataset.py +8 -12
- src/absa/data/hindi_loader.py +4 -5
- src/absa/data/lang_detect.py +2 -0
- src/absa/data/preprocess.py +1 -0
- src/absa/data/transliterate.py +3 -9
- src/absa/evaluation/benchmark_latency.py +11 -28
- src/absa/evaluation/cross_lingual_eval.py +11 -22
- src/absa/evaluation/final_eval.py +1 -1
- src/absa/models/baseline.py +17 -25
- src/absa/models/export_onnx.py +4 -10
- src/absa/models/train_aspect_extraction.py +12 -14
- 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 |
-
#
|
| 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 |
-
|
| 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 |
-
|
| 23 |
|
| 24 |
lint:
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
typecheck:
|
| 28 |
-
|
| 29 |
|
| 30 |
security:
|
| 31 |
bandit -r api src/absa
|
| 32 |
|
| 33 |
coverage:
|
| 34 |
-
|
| 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
|
| 64 |
│ ├── main.py # Entry point — uvicorn api.main:app
|
| 65 |
-
│ ├──
|
| 66 |
-
│ ├──
|
| 67 |
-
│ ├──
|
|
|
|
| 68 |
│ ├── services/ # ABSA inference pipeline, language detection
|
| 69 |
│ └── tasks/ # Celery batch processing workers
|
| 70 |
-
├── src/absa/ # Core ML library (
|
| 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 |
-
├──
|
| 82 |
-
│ ├──
|
| 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/ #
|
|
|
|
| 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 |
-
|
|
|
|
| 98 |
```
|
| 99 |
|
| 100 |
---
|
|
@@ -177,7 +178,10 @@ dvc push # Upload to remote storage
|
|
| 177 |
### Run Tests
|
| 178 |
|
| 179 |
```bash
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
import tempfile
|
| 7 |
import time
|
| 8 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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) >
|
| 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
|
| 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
|
| 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 -
|
| 31 |
-
uvicorn
|
| 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
|
| 21 |
]
|
| 22 |
},
|
| 23 |
{
|
|
@@ -49,7 +49,7 @@
|
|
| 49 |
"outputs": [],
|
| 50 |
"source": [
|
| 51 |
"# Prepare dataset\n",
|
| 52 |
-
"!PYTHONPATH=
|
| 53 |
]
|
| 54 |
},
|
| 55 |
{
|
|
@@ -59,7 +59,7 @@
|
|
| 59 |
"outputs": [],
|
| 60 |
"source": [
|
| 61 |
"# Run Aspect Extraction Training\n",
|
| 62 |
-
"!PYTHONPATH=
|
| 63 |
]
|
| 64 |
},
|
| 65 |
{
|
|
@@ -69,7 +69,7 @@
|
|
| 69 |
"outputs": [],
|
| 70 |
"source": [
|
| 71 |
"# Run Sentiment Classification Training\n",
|
| 72 |
-
"!PYTHONPATH=
|
| 73 |
]
|
| 74 |
},
|
| 75 |
{
|
|
@@ -79,7 +79,7 @@
|
|
| 79 |
"outputs": [],
|
| 80 |
"source": [
|
| 81 |
"# Run Baseline as well\n",
|
| 82 |
-
"!PYTHONPATH=
|
| 83 |
]
|
| 84 |
},
|
| 85 |
{
|
|
@@ -89,7 +89,7 @@
|
|
| 89 |
"outputs": [],
|
| 90 |
"source": [
|
| 91 |
"# Cross-lingual Evaluation\n",
|
| 92 |
-
"!PYTHONPATH=
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 6 |
from evidently.metric_preset import DataDriftPreset, TextOverviewPreset
|
|
|
|
| 7 |
from sqlalchemy import create_engine
|
| 8 |
-
|
| 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 |
-
|
| 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 |
return
|
| 41 |
|
| 42 |
# Run Evidently report
|
| 43 |
print("Running Evidently drift report...")
|
| 44 |
-
report = Report(metrics=[
|
| 45 |
-
|
| 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 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 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 |
-
(
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
("# Prepare dataset\n!PYTHONPATH=src python -m absa.data.hf_dataset", "code"),
|
| 53 |
-
(
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
("# Run Baseline as well\n!PYTHONPATH=src python -m absa.models.baseline", "code"),
|
| 56 |
-
(
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
]
|
| 59 |
-
|
| 60 |
comparison_cells = [
|
| 61 |
-
(
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
]
|
| 68 |
-
|
| 69 |
-
create_notebook("
|
| 70 |
create_notebook("03_model_comparison.ipynb", comparison_cells)
|
| 71 |
|
| 72 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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.
|
|
|
|
| 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
|
| 11 |
-
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 5 |
-
from
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 9 |
from sklearn.model_selection import train_test_split
|
| 10 |
-
|
| 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 |
-
|
| 65 |
-
|
| 66 |
-
|
| 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 |
-
|
| 74 |
y_test = test_cls["label"].values
|
| 75 |
|
| 76 |
-
print(f"Training on {len(
|
| 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 |
-
|
| 87 |
-
classifier.fit(
|
| 88 |
|
| 89 |
# Evaluate
|
| 90 |
print("Evaluating...")
|
| 91 |
-
|
| 92 |
-
y_pred = classifier.predict(
|
| 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 |
-
|
| 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)
|