Spaces:
Runtime error
Runtime error
Aryan Mishra commited on
Commit ·
6c2294e
1
Parent(s): 927450f
Refactor API and training scripts
Browse filesCleans up formatting and typing across API, data, model, evaluation, and training modules, while fixing the CI Dockerfile path and adding mypy to the CI install step. Also updates the README with a Python-first architecture note.
- .github/workflows/ci.yml +2 -2
- README.md +7 -0
- api/main.py +3 -2
- api/middleware/dependencies.py +1 -0
- api/models/db_models.py +12 -5
- api/models/schemas.py +8 -4
- api/routes/predict.py +31 -31
- api/routes/results.py +4 -6
- api/services/absa_pipeline.py +323 -86
- api/services/lang_service.py +8 -7
- api/tasks/__init__.py +1 -3
- api/tasks/batch_tasks.py +52 -18
- src/data/augmentation.py +29 -21
- src/data/bio_tagger.py +46 -34
- src/data/dataset.py +31 -26
- src/data/hf_dataset.py +87 -60
- src/data/hindi_loader.py +14 -12
- src/data/lang_detect.py +14 -14
- src/data/preprocess.py +13 -12
- src/data/transliterate.py +15 -8
- src/evaluation/benchmark_latency.py +66 -31
- src/evaluation/cross_lingual_eval.py +58 -38
- src/evaluation/final_eval.py +34 -12
- src/models/baseline.py +51 -40
- src/models/export_onnx.py +27 -17
- src/models/train_aspect_extraction.py +36 -33
- src/models/train_joint_absa.py +62 -42
- src/models/train_multilingual.py +43 -33
- src/models/train_qlora.py +31 -24
- src/models/train_sentiment.py +49 -33
- src/training/mlflow_utils.py +29 -18
.github/workflows/ci.yml
CHANGED
|
@@ -7,7 +7,7 @@ jobs:
|
|
| 7 |
- uses: actions/checkout@v4
|
| 8 |
- uses: actions/setup-python@v5
|
| 9 |
with: {python-version: "3.11"}
|
| 10 |
-
- run: pip install -r requirements.txt
|
| 11 |
- run: PYTHONPATH=. pytest tests/ -v --tb=short
|
| 12 |
- run: PYTHONPATH=. python -m mypy src/ --ignore-missing-imports
|
| 13 |
|
|
@@ -33,6 +33,6 @@ jobs:
|
|
| 33 |
- uses: docker/build-push-action@v5
|
| 34 |
with:
|
| 35 |
context: .
|
| 36 |
-
file: docker/Dockerfile.api.prod
|
| 37 |
push: ${{github.ref == 'refs/heads/main'}}
|
| 38 |
tags: ghcr.io/${{github.repository}}/api:latest
|
|
|
|
| 7 |
- uses: actions/checkout@v4
|
| 8 |
- uses: actions/setup-python@v5
|
| 9 |
with: {python-version: "3.11"}
|
| 10 |
+
- run: pip install -r requirements.txt mypy
|
| 11 |
- run: PYTHONPATH=. pytest tests/ -v --tb=short
|
| 12 |
- run: PYTHONPATH=. python -m mypy src/ --ignore-missing-imports
|
| 13 |
|
|
|
|
| 33 |
- uses: docker/build-push-action@v5
|
| 34 |
with:
|
| 35 |
context: .
|
| 36 |
+
file: config/docker/Dockerfile.api.prod
|
| 37 |
push: ${{github.ref == 'refs/heads/main'}}
|
| 38 |
tags: ghcr.io/${{github.repository}}/api:latest
|
README.md
CHANGED
|
@@ -22,6 +22,13 @@ The system leverages state-of-the-art models like **XLM-RoBERTa** and **IndicBER
|
|
| 22 |
- **MLOps Integrated**: Complete integration with DVC (Data Version Control) for pipeline reproducibility, MLflow for experiment tracking, and Evidently AI for data drift monitoring.
|
| 23 |
- **Scalable Architecture**: Support for async tasks via Celery + Redis, robust data storage via PostgreSQL, and metric exporting using Prometheus.
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
## 🏗️ Repository Structure
|
| 26 |
|
| 27 |
```text
|
|
|
|
| 22 |
- **MLOps Integrated**: Complete integration with DVC (Data Version Control) for pipeline reproducibility, MLflow for experiment tracking, and Evidently AI for data drift monitoring.
|
| 23 |
- **Scalable Architecture**: Support for async tasks via Celery + Redis, robust data storage via PostgreSQL, and metric exporting using Prometheus.
|
| 24 |
|
| 25 |
+
## 🐍 Python-First Architecture
|
| 26 |
+
|
| 27 |
+
This repository is designed following a **Python-first paradigm**:
|
| 28 |
+
- **Python (67.5%)**: Handling all business logic, data processing, configuration, API routing, ML inference, and utility functions using FastAPI and Python data science libraries.
|
| 29 |
+
- **JavaScript/TypeScript (32.5%)**: Strictly limited to the frontend `dashboard/` directory, used *only* for the React UI, component rendering, browser events, and client-side state.
|
| 30 |
+
- *Note: There is no backend or ML logic written in JavaScript.*
|
| 31 |
+
|
| 32 |
## 🏗️ Repository Structure
|
| 33 |
|
| 34 |
```text
|
api/main.py
CHANGED
|
@@ -2,7 +2,6 @@ from fastapi import FastAPI
|
|
| 2 |
from contextlib import asynccontextmanager
|
| 3 |
from dotenv import load_dotenv
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
-
from prometheus_fastapi_instrumentator import Instrumentator
|
| 6 |
|
| 7 |
load_dotenv()
|
| 8 |
|
|
@@ -12,6 +11,7 @@ from api.services.absa_pipeline import pipeline
|
|
| 12 |
from api.models.db_models import Base
|
| 13 |
from api.middleware.dependencies import engine
|
| 14 |
|
|
|
|
| 15 |
@asynccontextmanager
|
| 16 |
async def lifespan(app: FastAPI):
|
| 17 |
# Startup
|
|
@@ -25,11 +25,12 @@ async def lifespan(app: FastAPI):
|
|
| 25 |
# Shutdown
|
| 26 |
print("Shutting down...")
|
| 27 |
|
|
|
|
| 28 |
app = FastAPI(
|
| 29 |
title="Multilingual ABSA API",
|
| 30 |
description="Aspect-Based Sentiment Analysis for English and Hindi",
|
| 31 |
version="1.0.0",
|
| 32 |
-
lifespan=lifespan
|
| 33 |
)
|
| 34 |
|
| 35 |
# Allow the dashboard (and any origin in dev) to call the API
|
|
|
|
| 2 |
from contextlib import asynccontextmanager
|
| 3 |
from dotenv import load_dotenv
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 5 |
|
| 6 |
load_dotenv()
|
| 7 |
|
|
|
|
| 11 |
from api.models.db_models import Base
|
| 12 |
from api.middleware.dependencies import engine
|
| 13 |
|
| 14 |
+
|
| 15 |
@asynccontextmanager
|
| 16 |
async def lifespan(app: FastAPI):
|
| 17 |
# Startup
|
|
|
|
| 25 |
# Shutdown
|
| 26 |
print("Shutting down...")
|
| 27 |
|
| 28 |
+
|
| 29 |
app = FastAPI(
|
| 30 |
title="Multilingual ABSA API",
|
| 31 |
description="Aspect-Based Sentiment Analysis for English and Hindi",
|
| 32 |
version="1.0.0",
|
| 33 |
+
lifespan=lifespan,
|
| 34 |
)
|
| 35 |
|
| 36 |
# Allow the dashboard (and any origin in dev) to call the API
|
api/middleware/dependencies.py
CHANGED
|
@@ -16,6 +16,7 @@ if not DATABASE_URL:
|
|
| 16 |
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
| 17 |
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 18 |
|
|
|
|
| 19 |
def get_db():
|
| 20 |
db = SessionLocal()
|
| 21 |
try:
|
|
|
|
| 16 |
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
| 17 |
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 18 |
|
| 19 |
+
|
| 20 |
def get_db():
|
| 21 |
db = SessionLocal()
|
| 22 |
try:
|
api/models/db_models.py
CHANGED
|
@@ -5,18 +5,22 @@ from datetime import datetime, timezone
|
|
| 5 |
|
| 6 |
Base = declarative_base()
|
| 7 |
|
|
|
|
| 8 |
class Review(Base):
|
| 9 |
__tablename__ = "reviews"
|
| 10 |
-
|
| 11 |
id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 12 |
text = Column(Text, nullable=False)
|
| 13 |
language = Column(String(10), nullable=False)
|
| 14 |
-
created_at = Column(
|
|
|
|
|
|
|
| 15 |
processing_time_ms = Column(Float, nullable=False)
|
| 16 |
|
|
|
|
| 17 |
class AspectResult(Base):
|
| 18 |
__tablename__ = "aspect_results"
|
| 19 |
-
|
| 20 |
id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 21 |
review_id = Column(Uuid(as_uuid=True), ForeignKey("reviews.id"), nullable=False)
|
| 22 |
aspect = Column(String(255), nullable=False)
|
|
@@ -25,12 +29,15 @@ class AspectResult(Base):
|
|
| 25 |
start_pos = Column(Integer, nullable=False)
|
| 26 |
end_pos = Column(Integer, nullable=False)
|
| 27 |
|
|
|
|
| 28 |
class BatchJob(Base):
|
| 29 |
__tablename__ = "batch_jobs"
|
| 30 |
-
|
| 31 |
id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 32 |
status = Column(String(50), nullable=False, default="queued")
|
| 33 |
total = Column(Integer, nullable=False)
|
| 34 |
processed = Column(Integer, nullable=False, default=0)
|
| 35 |
-
created_at = Column(
|
|
|
|
|
|
|
| 36 |
completed_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
| 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)
|
|
|
|
| 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/models/schemas.py
CHANGED
|
@@ -1,35 +1,39 @@
|
|
| 1 |
from pydantic import BaseModel, ConfigDict
|
| 2 |
from typing import Optional, List
|
| 3 |
|
|
|
|
| 4 |
class ReviewInput(BaseModel):
|
| 5 |
text: str
|
| 6 |
language: Optional[str] = None
|
| 7 |
-
|
| 8 |
model_config = ConfigDict(from_attributes=True)
|
| 9 |
|
|
|
|
| 10 |
class AspectSentiment(BaseModel):
|
| 11 |
aspect: str
|
| 12 |
sentiment: str
|
| 13 |
confidence: float
|
| 14 |
start: int
|
| 15 |
end: int
|
| 16 |
-
|
| 17 |
model_config = ConfigDict(from_attributes=True)
|
| 18 |
|
|
|
|
| 19 |
class PredictionResponse(BaseModel):
|
| 20 |
text: str
|
| 21 |
language: str
|
| 22 |
detected_language: str
|
| 23 |
aspects: List[AspectSentiment]
|
| 24 |
processing_time_ms: float
|
| 25 |
-
|
| 26 |
model_config = ConfigDict(from_attributes=True)
|
| 27 |
|
|
|
|
| 28 |
class BatchJobResponse(BaseModel):
|
| 29 |
job_id: str
|
| 30 |
status: str
|
| 31 |
total_reviews: int
|
| 32 |
processed: int
|
| 33 |
result_url: Optional[str] = None
|
| 34 |
-
|
| 35 |
model_config = ConfigDict(from_attributes=True)
|
|
|
|
| 1 |
from pydantic import BaseModel, ConfigDict
|
| 2 |
from typing import Optional, List
|
| 3 |
|
| 4 |
+
|
| 5 |
class ReviewInput(BaseModel):
|
| 6 |
text: str
|
| 7 |
language: Optional[str] = None
|
| 8 |
+
|
| 9 |
model_config = ConfigDict(from_attributes=True)
|
| 10 |
|
| 11 |
+
|
| 12 |
class AspectSentiment(BaseModel):
|
| 13 |
aspect: str
|
| 14 |
sentiment: str
|
| 15 |
confidence: float
|
| 16 |
start: int
|
| 17 |
end: int
|
| 18 |
+
|
| 19 |
model_config = ConfigDict(from_attributes=True)
|
| 20 |
|
| 21 |
+
|
| 22 |
class PredictionResponse(BaseModel):
|
| 23 |
text: str
|
| 24 |
language: str
|
| 25 |
detected_language: str
|
| 26 |
aspects: List[AspectSentiment]
|
| 27 |
processing_time_ms: float
|
| 28 |
+
|
| 29 |
model_config = ConfigDict(from_attributes=True)
|
| 30 |
|
| 31 |
+
|
| 32 |
class BatchJobResponse(BaseModel):
|
| 33 |
job_id: str
|
| 34 |
status: str
|
| 35 |
total_reviews: int
|
| 36 |
processed: int
|
| 37 |
result_url: Optional[str] = None
|
| 38 |
+
|
| 39 |
model_config = ConfigDict(from_attributes=True)
|
api/routes/predict.py
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
import pandas as pd
|
| 4 |
-
from typing import Dict
|
| 5 |
import os
|
| 6 |
import uuid
|
| 7 |
import tempfile
|
|
@@ -15,24 +14,25 @@ from api.tasks.batch_tasks import process_batch
|
|
| 15 |
|
| 16 |
router = APIRouter()
|
| 17 |
|
|
|
|
| 18 |
@router.post("/predict", response_model=PredictionResponse)
|
| 19 |
async def predict(request: ReviewInput, db: Session = Depends(get_db)):
|
| 20 |
try:
|
| 21 |
start_time = time.time()
|
| 22 |
-
|
| 23 |
# Inference
|
| 24 |
prediction = pipeline.predict(request.text, request.language)
|
| 25 |
-
|
| 26 |
# Save to DB
|
| 27 |
db_review = Review(
|
| 28 |
text=prediction.text,
|
| 29 |
language=prediction.language,
|
| 30 |
-
processing_time_ms=prediction.processing_time_ms
|
| 31 |
)
|
| 32 |
db.add(db_review)
|
| 33 |
db.commit()
|
| 34 |
db.refresh(db_review)
|
| 35 |
-
|
| 36 |
for asp in prediction.aspects:
|
| 37 |
db_aspect = AspectResult(
|
| 38 |
review_id=db_review.id,
|
|
@@ -40,74 +40,74 @@ async def predict(request: ReviewInput, db: Session = Depends(get_db)):
|
|
| 40 |
sentiment=asp.sentiment,
|
| 41 |
confidence=asp.confidence,
|
| 42 |
start_pos=asp.start,
|
| 43 |
-
end_pos=asp.end
|
| 44 |
)
|
| 45 |
db.add(db_aspect)
|
| 46 |
db.commit()
|
| 47 |
-
|
| 48 |
return prediction
|
| 49 |
except Exception as e:
|
| 50 |
raise HTTPException(status_code=500, detail=f"Model inference failed: {str(e)}")
|
| 51 |
|
|
|
|
| 52 |
@router.post("/batch", response_model=BatchJobResponse)
|
| 53 |
async def predict_batch(file: UploadFile = File(...), db: Session = Depends(get_db)):
|
| 54 |
-
if not file.filename.endswith(
|
| 55 |
raise HTTPException(status_code=422, detail="Only CSV files are allowed.")
|
| 56 |
-
|
| 57 |
try:
|
| 58 |
# Create temp file to read
|
| 59 |
with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp:
|
| 60 |
tmp.write(await file.read())
|
| 61 |
tmp_path = tmp.name
|
| 62 |
-
|
| 63 |
df = pd.read_csv(tmp_path)
|
| 64 |
if "text" not in df.columns:
|
| 65 |
os.unlink(tmp_path)
|
| 66 |
-
raise HTTPException(
|
| 67 |
-
|
|
|
|
|
|
|
| 68 |
if len(df) > 10000:
|
| 69 |
os.unlink(tmp_path)
|
| 70 |
-
raise HTTPException(
|
| 71 |
-
|
|
|
|
|
|
|
| 72 |
job_id_obj = uuid.uuid4()
|
| 73 |
job_id = str(job_id_obj)
|
| 74 |
-
db_job = BatchJob(
|
| 75 |
-
id=job_id_obj,
|
| 76 |
-
status="queued",
|
| 77 |
-
total=len(df),
|
| 78 |
-
processed=0
|
| 79 |
-
)
|
| 80 |
db.add(db_job)
|
| 81 |
db.commit()
|
| 82 |
-
|
| 83 |
# Queue Celery task
|
| 84 |
process_batch.delay(job_id, tmp_path)
|
| 85 |
-
|
| 86 |
return BatchJobResponse(
|
| 87 |
-
job_id=job_id,
|
| 88 |
-
status="queued",
|
| 89 |
-
total_reviews=len(df),
|
| 90 |
-
processed=0
|
| 91 |
)
|
| 92 |
except HTTPException:
|
| 93 |
raise
|
| 94 |
except Exception as e:
|
| 95 |
-
raise HTTPException(
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
@router.get("/status/{job_id}", response_model=BatchJobResponse)
|
| 98 |
async def get_batch_status(job_id: str, db: Session = Depends(get_db)):
|
| 99 |
job = db.query(BatchJob).filter(BatchJob.id == job_id).first()
|
| 100 |
if not job:
|
| 101 |
raise HTTPException(status_code=404, detail="Job not found")
|
| 102 |
-
|
| 103 |
result_url = None
|
| 104 |
if job.status == "completed":
|
| 105 |
result_url = f"/results/download/{job_id}"
|
| 106 |
-
|
| 107 |
return BatchJobResponse(
|
| 108 |
job_id=str(job.id),
|
| 109 |
status=job.status,
|
| 110 |
total_reviews=job.total,
|
| 111 |
processed=job.processed,
|
| 112 |
-
result_url=result_url
|
| 113 |
)
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
import pandas as pd
|
|
|
|
| 4 |
import os
|
| 5 |
import uuid
|
| 6 |
import tempfile
|
|
|
|
| 14 |
|
| 15 |
router = APIRouter()
|
| 16 |
|
| 17 |
+
|
| 18 |
@router.post("/predict", response_model=PredictionResponse)
|
| 19 |
async def predict(request: ReviewInput, db: Session = Depends(get_db)):
|
| 20 |
try:
|
| 21 |
start_time = time.time()
|
| 22 |
+
|
| 23 |
# Inference
|
| 24 |
prediction = pipeline.predict(request.text, request.language)
|
| 25 |
+
|
| 26 |
# Save to DB
|
| 27 |
db_review = Review(
|
| 28 |
text=prediction.text,
|
| 29 |
language=prediction.language,
|
| 30 |
+
processing_time_ms=prediction.processing_time_ms,
|
| 31 |
)
|
| 32 |
db.add(db_review)
|
| 33 |
db.commit()
|
| 34 |
db.refresh(db_review)
|
| 35 |
+
|
| 36 |
for asp in prediction.aspects:
|
| 37 |
db_aspect = AspectResult(
|
| 38 |
review_id=db_review.id,
|
|
|
|
| 40 |
sentiment=asp.sentiment,
|
| 41 |
confidence=asp.confidence,
|
| 42 |
start_pos=asp.start,
|
| 43 |
+
end_pos=asp.end,
|
| 44 |
)
|
| 45 |
db.add(db_aspect)
|
| 46 |
db.commit()
|
| 47 |
+
|
| 48 |
return prediction
|
| 49 |
except Exception as e:
|
| 50 |
raise HTTPException(status_code=500, detail=f"Model inference failed: {str(e)}")
|
| 51 |
|
| 52 |
+
|
| 53 |
@router.post("/batch", response_model=BatchJobResponse)
|
| 54 |
async def predict_batch(file: UploadFile = File(...), db: Session = Depends(get_db)):
|
| 55 |
+
if not file.filename.endswith(".csv"):
|
| 56 |
raise HTTPException(status_code=422, detail="Only CSV files are allowed.")
|
| 57 |
+
|
| 58 |
try:
|
| 59 |
# Create temp file to read
|
| 60 |
with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp:
|
| 61 |
tmp.write(await file.read())
|
| 62 |
tmp_path = tmp.name
|
| 63 |
+
|
| 64 |
df = pd.read_csv(tmp_path)
|
| 65 |
if "text" not in df.columns:
|
| 66 |
os.unlink(tmp_path)
|
| 67 |
+
raise HTTPException(
|
| 68 |
+
status_code=422, detail="CSV must contain a 'text' column."
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
if len(df) > 10000:
|
| 72 |
os.unlink(tmp_path)
|
| 73 |
+
raise HTTPException(
|
| 74 |
+
status_code=422, detail="Max 10,000 rows allowed per batch."
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
job_id_obj = uuid.uuid4()
|
| 78 |
job_id = str(job_id_obj)
|
| 79 |
+
db_job = BatchJob(id=job_id_obj, status="queued", total=len(df), processed=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
db.add(db_job)
|
| 81 |
db.commit()
|
| 82 |
+
|
| 83 |
# Queue Celery task
|
| 84 |
process_batch.delay(job_id, tmp_path)
|
| 85 |
+
|
| 86 |
return BatchJobResponse(
|
| 87 |
+
job_id=job_id, status="queued", total_reviews=len(df), processed=0
|
|
|
|
|
|
|
|
|
|
| 88 |
)
|
| 89 |
except HTTPException:
|
| 90 |
raise
|
| 91 |
except Exception as e:
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
status_code=500, detail=f"Batch processing failed: {str(e)}"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
|
| 97 |
@router.get("/status/{job_id}", response_model=BatchJobResponse)
|
| 98 |
async def get_batch_status(job_id: str, db: Session = Depends(get_db)):
|
| 99 |
job = db.query(BatchJob).filter(BatchJob.id == job_id).first()
|
| 100 |
if not job:
|
| 101 |
raise HTTPException(status_code=404, detail="Job not found")
|
| 102 |
+
|
| 103 |
result_url = None
|
| 104 |
if job.status == "completed":
|
| 105 |
result_url = f"/results/download/{job_id}"
|
| 106 |
+
|
| 107 |
return BatchJobResponse(
|
| 108 |
job_id=str(job.id),
|
| 109 |
status=job.status,
|
| 110 |
total_reviews=job.total,
|
| 111 |
processed=job.processed,
|
| 112 |
+
result_url=result_url,
|
| 113 |
)
|
api/routes/results.py
CHANGED
|
@@ -4,14 +4,12 @@ from typing import Dict
|
|
| 4 |
|
| 5 |
router = APIRouter()
|
| 6 |
|
|
|
|
| 7 |
@router.get("/health")
|
| 8 |
async def health_check() -> Dict[str, str]:
|
| 9 |
# Basic health check
|
| 10 |
-
return {
|
| 11 |
-
|
| 12 |
-
"model": "loaded",
|
| 13 |
-
"db": "connected"
|
| 14 |
-
}
|
| 15 |
|
| 16 |
@router.get("/info")
|
| 17 |
async def get_info() -> Dict[str, str]:
|
|
@@ -19,5 +17,5 @@ async def get_info() -> Dict[str, str]:
|
|
| 19 |
"model_name": "xlm-roberta-base-absa",
|
| 20 |
"version": "1.0",
|
| 21 |
"supported_languages": "en, hi",
|
| 22 |
-
"max_batch_size": os.getenv("MAX_BATCH_SIZE", "10000")
|
| 23 |
}
|
|
|
|
| 4 |
|
| 5 |
router = APIRouter()
|
| 6 |
|
| 7 |
+
|
| 8 |
@router.get("/health")
|
| 9 |
async def health_check() -> Dict[str, str]:
|
| 10 |
# Basic health check
|
| 11 |
+
return {"status": "ok", "model": "loaded", "db": "connected"}
|
| 12 |
+
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
@router.get("/info")
|
| 15 |
async def get_info() -> Dict[str, str]:
|
|
|
|
| 17 |
"model_name": "xlm-roberta-base-absa",
|
| 18 |
"version": "1.0",
|
| 19 |
"supported_languages": "en, hi",
|
| 20 |
+
"max_batch_size": os.getenv("MAX_BATCH_SIZE", "10000"),
|
| 21 |
}
|
api/services/absa_pipeline.py
CHANGED
|
@@ -28,90 +28,297 @@ try:
|
|
| 28 |
ORTModelForSequenceClassification,
|
| 29 |
)
|
| 30 |
from transformers import AutoTokenizer
|
|
|
|
| 31 |
OPTIMUM_AVAILABLE = True
|
| 32 |
except ImportError:
|
| 33 |
try:
|
| 34 |
from transformers import AutoTokenizer
|
|
|
|
| 35 |
OPTIMUM_AVAILABLE = False
|
| 36 |
except ImportError:
|
| 37 |
OPTIMUM_AVAILABLE = False
|
| 38 |
|
| 39 |
# ── Aspect keyword lexicon ────────────────────────────────────────────────────
|
| 40 |
# Ordered longest-first so multi-word matches win over single words.
|
| 41 |
-
ASPECT_PHRASES: List[str] = sorted(
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
# ── Sentiment lexicon ─────────────────────────────────────────────────────────
|
| 76 |
POSITIVE_WORDS = {
|
| 77 |
-
"excellent",
|
| 78 |
-
"
|
| 79 |
-
"
|
| 80 |
-
"
|
| 81 |
-
"
|
| 82 |
-
"
|
| 83 |
-
"
|
| 84 |
-
"
|
| 85 |
-
"
|
| 86 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
# Hindi positive (transliterated)
|
| 88 |
-
"badhiya",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
}
|
| 90 |
|
| 91 |
NEGATIVE_WORDS = {
|
| 92 |
-
"bad",
|
| 93 |
-
"
|
| 94 |
-
"
|
| 95 |
-
"
|
| 96 |
-
"
|
| 97 |
-
"
|
| 98 |
-
"
|
| 99 |
-
"
|
| 100 |
-
"
|
| 101 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
# Hindi negative (transliterated)
|
| 103 |
-
"kharab",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
|
| 106 |
NEGATION_WORDS = {
|
| 107 |
-
"not",
|
| 108 |
-
"
|
| 109 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
}
|
| 111 |
|
| 112 |
INTENSIFIERS = {
|
| 113 |
-
"very",
|
| 114 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
}
|
| 116 |
|
| 117 |
|
|
@@ -126,7 +333,7 @@ def _score_sentence(sentence: str) -> Tuple[float, float]:
|
|
| 126 |
while i < len(words):
|
| 127 |
w = words[i]
|
| 128 |
# Look-back for negation in previous 3 words
|
| 129 |
-
context = words[max(0, i - 3):i]
|
| 130 |
negated = any(n in context for n in NEGATION_WORDS)
|
| 131 |
# Look-back for intensifier
|
| 132 |
intensity = 1.5 if any(t in context for t in INTENSIFIERS) else 1.0
|
|
@@ -162,6 +369,7 @@ def _score_to_label(pos: float, neg: float) -> Tuple[str, float]:
|
|
| 162 |
|
| 163 |
# ── Main pipeline class ───────────────────────────────────────────────────────
|
| 164 |
|
|
|
|
| 165 |
class ABSAPipeline:
|
| 166 |
def __init__(self):
|
| 167 |
self.tokenizer = None
|
|
@@ -171,7 +379,12 @@ class ABSAPipeline:
|
|
| 171 |
self._lock = threading.Lock()
|
| 172 |
|
| 173 |
self.id2label = {0: "O", 1: "B-ASP", 2: "I-ASP"}
|
| 174 |
-
self.sentiment_id2label = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
def load_models(self):
|
| 177 |
"""Try to load custom ONNX models; mark ready immediately (no downloads)."""
|
|
@@ -186,8 +399,10 @@ class ABSAPipeline:
|
|
| 186 |
self.aspect_model = ORTModelForTokenClassification.from_pretrained(
|
| 187 |
hf_repo_id, subfolder="aspect_extraction_int8"
|
| 188 |
)
|
| 189 |
-
self.sentiment_model =
|
| 190 |
-
|
|
|
|
|
|
|
| 191 |
)
|
| 192 |
print("Custom ONNX models loaded.")
|
| 193 |
except Exception as e:
|
|
@@ -205,8 +420,14 @@ class ABSAPipeline:
|
|
| 205 |
try:
|
| 206 |
print(f"Loading custom ONNX models from {model_path_base}")
|
| 207 |
self.tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
|
| 208 |
-
self.aspect_model = ORTModelForTokenClassification.from_pretrained(
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
print("Custom ONNX models loaded.")
|
| 211 |
except Exception as e:
|
| 212 |
print(f"Custom model load skipped: {e}")
|
|
@@ -240,13 +461,19 @@ class ABSAPipeline:
|
|
| 240 |
# ── Custom ONNX path ──────────────────────────────────────────────────────
|
| 241 |
|
| 242 |
def _predict_onnx(self, text: str) -> List[AspectSentiment]:
|
| 243 |
-
inputs = self.tokenizer(
|
|
|
|
|
|
|
| 244 |
logits = self.aspect_model(**inputs).logits[0].detach().numpy()
|
| 245 |
preds = np.argmax(logits, axis=1)
|
| 246 |
tokens = self.tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
|
| 247 |
|
| 248 |
raw, current, start_idx = [], [], -1
|
| 249 |
-
skip = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
for idx, (tok, pred) in enumerate(zip(tokens, preds)):
|
| 251 |
if tok in skip:
|
| 252 |
continue
|
|
@@ -266,18 +493,26 @@ class ABSAPipeline:
|
|
| 266 |
|
| 267 |
results = []
|
| 268 |
for asp_text, s, e in raw:
|
| 269 |
-
seq_in = self.tokenizer(
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
sent_logits = self.sentiment_model(**seq_in).logits[0].detach().numpy()
|
| 272 |
exp = np.exp(sent_logits - sent_logits.max())
|
| 273 |
probs = exp / exp.sum()
|
| 274 |
cls = int(np.argmax(probs))
|
| 275 |
-
results.append(
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
| 281 |
return results
|
| 282 |
|
| 283 |
# ── Rule-based path ───────────────────────────────────────────────────────
|
|
@@ -291,9 +526,9 @@ class ABSAPipeline:
|
|
| 291 |
for aspect_label, start_char, end_char in found_aspects:
|
| 292 |
# Find the sentence(s) mentioning this aspect for focused scoring
|
| 293 |
aspect_lower = aspect_label.lower()
|
| 294 |
-
context_sentences = [
|
| 295 |
-
|
| 296 |
-
]
|
| 297 |
context = " ".join(context_sentences)
|
| 298 |
|
| 299 |
pos, neg = _score_sentence(context)
|
|
@@ -304,13 +539,15 @@ class ABSAPipeline:
|
|
| 304 |
neg += full_neg * 0.3
|
| 305 |
|
| 306 |
sentiment, confidence = _score_to_label(pos, neg)
|
| 307 |
-
results.append(
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
|
|
|
|
|
|
| 314 |
return results
|
| 315 |
|
| 316 |
def _extract_aspects(self, text_lower: str) -> List[Tuple[str, int, int]]:
|
|
|
|
| 28 |
ORTModelForSequenceClassification,
|
| 29 |
)
|
| 30 |
from transformers import AutoTokenizer
|
| 31 |
+
|
| 32 |
OPTIMUM_AVAILABLE = True
|
| 33 |
except ImportError:
|
| 34 |
try:
|
| 35 |
from transformers import AutoTokenizer
|
| 36 |
+
|
| 37 |
OPTIMUM_AVAILABLE = False
|
| 38 |
except ImportError:
|
| 39 |
OPTIMUM_AVAILABLE = False
|
| 40 |
|
| 41 |
# ── Aspect keyword lexicon ────────────────────────────────────────────────────
|
| 42 |
# Ordered longest-first so multi-word matches win over single words.
|
| 43 |
+
ASPECT_PHRASES: List[str] = sorted(
|
| 44 |
+
[
|
| 45 |
+
# Audio
|
| 46 |
+
"sound quality",
|
| 47 |
+
"audio quality",
|
| 48 |
+
"bass response",
|
| 49 |
+
"bass",
|
| 50 |
+
"treble",
|
| 51 |
+
"noise cancellation",
|
| 52 |
+
"active noise cancellation",
|
| 53 |
+
"anc",
|
| 54 |
+
"passive noise isolation",
|
| 55 |
+
"microphone quality",
|
| 56 |
+
"microphone",
|
| 57 |
+
"mic",
|
| 58 |
+
"speakers",
|
| 59 |
+
"speaker",
|
| 60 |
+
"audio",
|
| 61 |
+
"sound",
|
| 62 |
+
"volume",
|
| 63 |
+
# Battery / Power
|
| 64 |
+
"battery life",
|
| 65 |
+
"battery performance",
|
| 66 |
+
"charging speed",
|
| 67 |
+
"fast charging",
|
| 68 |
+
"wireless charging",
|
| 69 |
+
"charging case",
|
| 70 |
+
"battery",
|
| 71 |
+
"charging",
|
| 72 |
+
"power",
|
| 73 |
+
# Design / Build
|
| 74 |
+
"build quality",
|
| 75 |
+
"build",
|
| 76 |
+
"design",
|
| 77 |
+
"comfort",
|
| 78 |
+
"fit and finish",
|
| 79 |
+
"ergonomics",
|
| 80 |
+
"weight",
|
| 81 |
+
"size",
|
| 82 |
+
"material",
|
| 83 |
+
"finish",
|
| 84 |
+
"durability",
|
| 85 |
+
# Connectivity
|
| 86 |
+
"bluetooth connectivity",
|
| 87 |
+
"bluetooth",
|
| 88 |
+
"wifi",
|
| 89 |
+
"wi-fi",
|
| 90 |
+
"connectivity",
|
| 91 |
+
"wireless connection",
|
| 92 |
+
"pairing",
|
| 93 |
+
"latency",
|
| 94 |
+
"lag",
|
| 95 |
+
# Display
|
| 96 |
+
"display quality",
|
| 97 |
+
"screen quality",
|
| 98 |
+
"display",
|
| 99 |
+
"screen",
|
| 100 |
+
"resolution",
|
| 101 |
+
"brightness",
|
| 102 |
+
"touchscreen",
|
| 103 |
+
# Camera
|
| 104 |
+
"camera quality",
|
| 105 |
+
"image quality",
|
| 106 |
+
"video quality",
|
| 107 |
+
"camera",
|
| 108 |
+
"lens",
|
| 109 |
+
"photo",
|
| 110 |
+
# Performance
|
| 111 |
+
"performance",
|
| 112 |
+
"processing speed",
|
| 113 |
+
"speed",
|
| 114 |
+
"processor",
|
| 115 |
+
"ram",
|
| 116 |
+
"memory",
|
| 117 |
+
"loading time",
|
| 118 |
+
# Software / Features
|
| 119 |
+
"user interface",
|
| 120 |
+
"software",
|
| 121 |
+
"app",
|
| 122 |
+
"features",
|
| 123 |
+
"controls",
|
| 124 |
+
"buttons",
|
| 125 |
+
"touch controls",
|
| 126 |
+
# Value
|
| 127 |
+
"value for money",
|
| 128 |
+
"price",
|
| 129 |
+
"cost",
|
| 130 |
+
"value",
|
| 131 |
+
# Support / Delivery
|
| 132 |
+
"customer service",
|
| 133 |
+
"customer support",
|
| 134 |
+
"warranty",
|
| 135 |
+
"delivery",
|
| 136 |
+
"packaging",
|
| 137 |
+
# General
|
| 138 |
+
"quality",
|
| 139 |
+
"reliability",
|
| 140 |
+
"overall experience",
|
| 141 |
+
],
|
| 142 |
+
key=len,
|
| 143 |
+
reverse=True,
|
| 144 |
+
)
|
| 145 |
|
| 146 |
# ── Sentiment lexicon ─────────────────────────────────────────────────────────
|
| 147 |
POSITIVE_WORDS = {
|
| 148 |
+
"excellent",
|
| 149 |
+
"great",
|
| 150 |
+
"amazing",
|
| 151 |
+
"outstanding",
|
| 152 |
+
"superb",
|
| 153 |
+
"fantastic",
|
| 154 |
+
"wonderful",
|
| 155 |
+
"perfect",
|
| 156 |
+
"impressive",
|
| 157 |
+
"exceptional",
|
| 158 |
+
"brilliant",
|
| 159 |
+
"splendid",
|
| 160 |
+
"good",
|
| 161 |
+
"nice",
|
| 162 |
+
"solid",
|
| 163 |
+
"strong",
|
| 164 |
+
"reliable",
|
| 165 |
+
"consistent",
|
| 166 |
+
"smooth",
|
| 167 |
+
"clear",
|
| 168 |
+
"crisp",
|
| 169 |
+
"rich",
|
| 170 |
+
"deep",
|
| 171 |
+
"powerful",
|
| 172 |
+
"comfortable",
|
| 173 |
+
"enjoyable",
|
| 174 |
+
"satisfied",
|
| 175 |
+
"happy",
|
| 176 |
+
"love",
|
| 177 |
+
"loved",
|
| 178 |
+
"like",
|
| 179 |
+
"loved",
|
| 180 |
+
"commendable",
|
| 181 |
+
"recommend",
|
| 182 |
+
"recommended",
|
| 183 |
+
"worth",
|
| 184 |
+
"affordable",
|
| 185 |
+
"value",
|
| 186 |
+
"effective",
|
| 187 |
+
"efficient",
|
| 188 |
+
"accurate",
|
| 189 |
+
"precise",
|
| 190 |
+
"sharp",
|
| 191 |
+
"vibrant",
|
| 192 |
+
"vivid",
|
| 193 |
+
"fast",
|
| 194 |
+
"quick",
|
| 195 |
+
"snappy",
|
| 196 |
+
"instant",
|
| 197 |
+
"stable",
|
| 198 |
+
"durable",
|
| 199 |
+
"sturdy",
|
| 200 |
+
"premium",
|
| 201 |
+
"high-quality",
|
| 202 |
+
"high quality",
|
| 203 |
+
"top-notch",
|
| 204 |
+
"top notch",
|
| 205 |
+
"long",
|
| 206 |
+
"lasting",
|
| 207 |
+
"enduring",
|
| 208 |
+
"impressive",
|
| 209 |
+
"praise",
|
| 210 |
+
"appreciate",
|
| 211 |
# Hindi positive (transliterated)
|
| 212 |
+
"badhiya",
|
| 213 |
+
"achha",
|
| 214 |
+
"accha",
|
| 215 |
+
"shandar",
|
| 216 |
+
"zabardast",
|
| 217 |
+
"mast",
|
| 218 |
}
|
| 219 |
|
| 220 |
NEGATIVE_WORDS = {
|
| 221 |
+
"bad",
|
| 222 |
+
"poor",
|
| 223 |
+
"terrible",
|
| 224 |
+
"awful",
|
| 225 |
+
"horrible",
|
| 226 |
+
"dreadful",
|
| 227 |
+
"atrocious",
|
| 228 |
+
"disappointing",
|
| 229 |
+
"disappointed",
|
| 230 |
+
"mediocre",
|
| 231 |
+
"weak",
|
| 232 |
+
"subpar",
|
| 233 |
+
"inferior",
|
| 234 |
+
"cheap",
|
| 235 |
+
"flimsy",
|
| 236 |
+
"fragile",
|
| 237 |
+
"unreliable",
|
| 238 |
+
"inconsistent",
|
| 239 |
+
"unstable",
|
| 240 |
+
"slow",
|
| 241 |
+
"sluggish",
|
| 242 |
+
"laggy",
|
| 243 |
+
"lag",
|
| 244 |
+
"delay",
|
| 245 |
+
"delayed",
|
| 246 |
+
"glitchy",
|
| 247 |
+
"buggy",
|
| 248 |
+
"noisy",
|
| 249 |
+
"distorted",
|
| 250 |
+
"muffled",
|
| 251 |
+
"blurry",
|
| 252 |
+
"dim",
|
| 253 |
+
"dull",
|
| 254 |
+
"flat",
|
| 255 |
+
"short",
|
| 256 |
+
"low",
|
| 257 |
+
"limited",
|
| 258 |
+
"lacking",
|
| 259 |
+
"missing",
|
| 260 |
+
"absent",
|
| 261 |
+
"expensive",
|
| 262 |
+
"overpriced",
|
| 263 |
+
"pricey",
|
| 264 |
+
"costly",
|
| 265 |
+
"uncomfortable",
|
| 266 |
+
"annoying",
|
| 267 |
+
"frustrating",
|
| 268 |
+
"irritating",
|
| 269 |
+
"failed",
|
| 270 |
+
"failure",
|
| 271 |
+
"broken",
|
| 272 |
+
"defective",
|
| 273 |
+
"faulty",
|
| 274 |
+
"average",
|
| 275 |
+
"ordinary",
|
| 276 |
+
"basic",
|
| 277 |
+
"minimal",
|
| 278 |
# Hindi negative (transliterated)
|
| 279 |
+
"kharab",
|
| 280 |
+
"bekaar",
|
| 281 |
+
"bura",
|
| 282 |
+
"ganda",
|
| 283 |
+
"faltu",
|
| 284 |
}
|
| 285 |
|
| 286 |
NEGATION_WORDS = {
|
| 287 |
+
"not",
|
| 288 |
+
"no",
|
| 289 |
+
"never",
|
| 290 |
+
"neither",
|
| 291 |
+
"nor",
|
| 292 |
+
"barely",
|
| 293 |
+
"hardly",
|
| 294 |
+
"scarcely",
|
| 295 |
+
"doesn't",
|
| 296 |
+
"don't",
|
| 297 |
+
"didn't",
|
| 298 |
+
"isn't",
|
| 299 |
+
"aren't",
|
| 300 |
+
"wasn't",
|
| 301 |
+
"weren't",
|
| 302 |
+
"without",
|
| 303 |
+
"lack",
|
| 304 |
+
"lacks",
|
| 305 |
+
"lacking",
|
| 306 |
+
"failed",
|
| 307 |
+
"fails",
|
| 308 |
}
|
| 309 |
|
| 310 |
INTENSIFIERS = {
|
| 311 |
+
"very",
|
| 312 |
+
"extremely",
|
| 313 |
+
"incredibly",
|
| 314 |
+
"absolutely",
|
| 315 |
+
"truly",
|
| 316 |
+
"really",
|
| 317 |
+
"highly",
|
| 318 |
+
"remarkably",
|
| 319 |
+
"exceptionally",
|
| 320 |
+
"super",
|
| 321 |
+
"too",
|
| 322 |
}
|
| 323 |
|
| 324 |
|
|
|
|
| 333 |
while i < len(words):
|
| 334 |
w = words[i]
|
| 335 |
# Look-back for negation in previous 3 words
|
| 336 |
+
context = words[max(0, i - 3) : i]
|
| 337 |
negated = any(n in context for n in NEGATION_WORDS)
|
| 338 |
# Look-back for intensifier
|
| 339 |
intensity = 1.5 if any(t in context for t in INTENSIFIERS) else 1.0
|
|
|
|
| 369 |
|
| 370 |
# ── Main pipeline class ───────────────────────────────────────────────────────
|
| 371 |
|
| 372 |
+
|
| 373 |
class ABSAPipeline:
|
| 374 |
def __init__(self):
|
| 375 |
self.tokenizer = None
|
|
|
|
| 379 |
self._lock = threading.Lock()
|
| 380 |
|
| 381 |
self.id2label = {0: "O", 1: "B-ASP", 2: "I-ASP"}
|
| 382 |
+
self.sentiment_id2label = {
|
| 383 |
+
0: "positive",
|
| 384 |
+
1: "negative",
|
| 385 |
+
2: "neutral",
|
| 386 |
+
3: "conflict",
|
| 387 |
+
}
|
| 388 |
|
| 389 |
def load_models(self):
|
| 390 |
"""Try to load custom ONNX models; mark ready immediately (no downloads)."""
|
|
|
|
| 399 |
self.aspect_model = ORTModelForTokenClassification.from_pretrained(
|
| 400 |
hf_repo_id, subfolder="aspect_extraction_int8"
|
| 401 |
)
|
| 402 |
+
self.sentiment_model = (
|
| 403 |
+
ORTModelForSequenceClassification.from_pretrained(
|
| 404 |
+
hf_repo_id, subfolder="sentiment_int8"
|
| 405 |
+
)
|
| 406 |
)
|
| 407 |
print("Custom ONNX models loaded.")
|
| 408 |
except Exception as e:
|
|
|
|
| 420 |
try:
|
| 421 |
print(f"Loading custom ONNX models from {model_path_base}")
|
| 422 |
self.tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
|
| 423 |
+
self.aspect_model = ORTModelForTokenClassification.from_pretrained(
|
| 424 |
+
str(aspect_path)
|
| 425 |
+
)
|
| 426 |
+
self.sentiment_model = (
|
| 427 |
+
ORTModelForSequenceClassification.from_pretrained(
|
| 428 |
+
str(sentiment_path)
|
| 429 |
+
)
|
| 430 |
+
)
|
| 431 |
print("Custom ONNX models loaded.")
|
| 432 |
except Exception as e:
|
| 433 |
print(f"Custom model load skipped: {e}")
|
|
|
|
| 461 |
# ── Custom ONNX path ──────────────────────────────────────────────────────
|
| 462 |
|
| 463 |
def _predict_onnx(self, text: str) -> List[AspectSentiment]:
|
| 464 |
+
inputs = self.tokenizer(
|
| 465 |
+
text, return_tensors="pt", truncation=True, max_length=128
|
| 466 |
+
)
|
| 467 |
logits = self.aspect_model(**inputs).logits[0].detach().numpy()
|
| 468 |
preds = np.argmax(logits, axis=1)
|
| 469 |
tokens = self.tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
|
| 470 |
|
| 471 |
raw, current, start_idx = [], [], -1
|
| 472 |
+
skip = {
|
| 473 |
+
self.tokenizer.cls_token,
|
| 474 |
+
self.tokenizer.sep_token,
|
| 475 |
+
self.tokenizer.pad_token,
|
| 476 |
+
}
|
| 477 |
for idx, (tok, pred) in enumerate(zip(tokens, preds)):
|
| 478 |
if tok in skip:
|
| 479 |
continue
|
|
|
|
| 493 |
|
| 494 |
results = []
|
| 495 |
for asp_text, s, e in raw:
|
| 496 |
+
seq_in = self.tokenizer(
|
| 497 |
+
text,
|
| 498 |
+
text_pair=asp_text,
|
| 499 |
+
return_tensors="pt",
|
| 500 |
+
truncation=True,
|
| 501 |
+
max_length=128,
|
| 502 |
+
)
|
| 503 |
sent_logits = self.sentiment_model(**seq_in).logits[0].detach().numpy()
|
| 504 |
exp = np.exp(sent_logits - sent_logits.max())
|
| 505 |
probs = exp / exp.sum()
|
| 506 |
cls = int(np.argmax(probs))
|
| 507 |
+
results.append(
|
| 508 |
+
AspectSentiment(
|
| 509 |
+
aspect=asp_text,
|
| 510 |
+
sentiment=self.sentiment_id2label.get(cls, "neutral"),
|
| 511 |
+
confidence=round(float(probs[cls]), 3),
|
| 512 |
+
start=s,
|
| 513 |
+
end=e,
|
| 514 |
+
)
|
| 515 |
+
)
|
| 516 |
return results
|
| 517 |
|
| 518 |
# ── Rule-based path ───────────────────────────────────────────────────────
|
|
|
|
| 526 |
for aspect_label, start_char, end_char in found_aspects:
|
| 527 |
# Find the sentence(s) mentioning this aspect for focused scoring
|
| 528 |
aspect_lower = aspect_label.lower()
|
| 529 |
+
context_sentences = [s for s in sentences if aspect_lower in s.lower()] or [
|
| 530 |
+
text
|
| 531 |
+
]
|
| 532 |
context = " ".join(context_sentences)
|
| 533 |
|
| 534 |
pos, neg = _score_sentence(context)
|
|
|
|
| 539 |
neg += full_neg * 0.3
|
| 540 |
|
| 541 |
sentiment, confidence = _score_to_label(pos, neg)
|
| 542 |
+
results.append(
|
| 543 |
+
AspectSentiment(
|
| 544 |
+
aspect=aspect_label,
|
| 545 |
+
sentiment=sentiment,
|
| 546 |
+
confidence=confidence,
|
| 547 |
+
start=start_char,
|
| 548 |
+
end=end_char,
|
| 549 |
+
)
|
| 550 |
+
)
|
| 551 |
return results
|
| 552 |
|
| 553 |
def _extract_aspects(self, text_lower: str) -> List[Tuple[str, int, int]]:
|
api/services/lang_service.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import fasttext
|
| 2 |
-
import os
|
| 3 |
from pathlib import Path
|
| 4 |
|
|
|
|
| 5 |
class LanguageService:
|
| 6 |
def __init__(self):
|
| 7 |
# Using a simple heuristic or fasttext if available.
|
|
@@ -15,16 +15,17 @@ 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(
|
| 19 |
-
if lang in [
|
| 20 |
return lang
|
| 21 |
# Default to en if unknown or other
|
| 22 |
-
return
|
| 23 |
else:
|
| 24 |
# Simple heuristic fallback
|
| 25 |
-
hindi_chars = sum(1 for c in text if
|
| 26 |
if hindi_chars > 0:
|
| 27 |
-
return
|
| 28 |
-
return
|
|
|
|
| 29 |
|
| 30 |
lang_service = LanguageService()
|
|
|
|
| 1 |
import fasttext
|
|
|
|
| 2 |
from pathlib import Path
|
| 3 |
|
| 4 |
+
|
| 5 |
class LanguageService:
|
| 6 |
def __init__(self):
|
| 7 |
# Using a simple heuristic or fasttext if available.
|
|
|
|
| 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
|
| 22 |
+
return "en"
|
| 23 |
else:
|
| 24 |
# Simple heuristic fallback
|
| 25 |
+
hindi_chars = sum(1 for c in text if "\u0900" <= c <= "\u097f")
|
| 26 |
if hindi_chars > 0:
|
| 27 |
+
return "hi"
|
| 28 |
+
return "en"
|
| 29 |
+
|
| 30 |
|
| 31 |
lang_service = LanguageService()
|
api/tasks/__init__.py
CHANGED
|
@@ -4,9 +4,7 @@ import os
|
|
| 4 |
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
| 5 |
|
| 6 |
celery_app = Celery(
|
| 7 |
-
"absa_tasks",
|
| 8 |
-
broker=redis_url,
|
| 9 |
-
backend=redis_url.replace("/0", "/1")
|
| 10 |
)
|
| 11 |
|
| 12 |
celery_app.conf.update(
|
|
|
|
| 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(
|
api/tasks/batch_tasks.py
CHANGED
|
@@ -7,6 +7,7 @@ import os
|
|
| 7 |
import csv
|
| 8 |
from datetime import datetime, timezone
|
| 9 |
|
|
|
|
| 10 |
@celery_app.task(bind=True)
|
| 11 |
def process_batch(self, job_id: str, file_path: str):
|
| 12 |
db = SessionLocal()
|
|
@@ -14,7 +15,7 @@ def process_batch(self, job_id: str, file_path: str):
|
|
| 14 |
job = db.query(BatchJob).filter(BatchJob.id == job_id).first()
|
| 15 |
if not job:
|
| 16 |
return
|
| 17 |
-
|
| 18 |
job.status = "processing"
|
| 19 |
db.commit()
|
| 20 |
|
|
@@ -22,35 +23,46 @@ def process_batch(self, job_id: str, file_path: str):
|
|
| 22 |
df = pd.read_csv(file_path)
|
| 23 |
if "text" not in df.columns:
|
| 24 |
raise ValueError("CSV must contain a 'text' column.")
|
| 25 |
-
|
| 26 |
texts = df["text"].tolist()
|
| 27 |
batch_size = 32
|
| 28 |
-
|
| 29 |
results_dir = "data/results"
|
| 30 |
os.makedirs(results_dir, exist_ok=True)
|
| 31 |
result_file = f"{results_dir}/{job_id}.csv"
|
| 32 |
-
|
| 33 |
processed_count = 0
|
| 34 |
-
|
| 35 |
with open(result_file, "w", newline="", encoding="utf-8") as f:
|
| 36 |
writer = csv.writer(f)
|
| 37 |
-
writer.writerow(
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
for i in range(0, len(texts), batch_size):
|
| 40 |
-
batch_texts = texts[i:i+batch_size]
|
| 41 |
predictions = pipeline.predict_batch(batch_texts)
|
| 42 |
-
|
| 43 |
for pred in predictions:
|
| 44 |
# Save Review
|
| 45 |
db_review = Review(
|
| 46 |
text=pred.text,
|
| 47 |
language=pred.language,
|
| 48 |
-
processing_time_ms=pred.processing_time_ms
|
| 49 |
)
|
| 50 |
db.add(db_review)
|
| 51 |
db.commit()
|
| 52 |
db.refresh(db_review)
|
| 53 |
-
|
| 54 |
# Save Aspects & CSV
|
| 55 |
for asp in pred.aspects:
|
| 56 |
db_aspect = AspectResult(
|
|
@@ -59,21 +71,43 @@ def process_batch(self, job_id: str, file_path: str):
|
|
| 59 |
sentiment=asp.sentiment,
|
| 60 |
confidence=asp.confidence,
|
| 61 |
start_pos=asp.start,
|
| 62 |
-
end_pos=asp.end
|
| 63 |
)
|
| 64 |
db.add(db_aspect)
|
| 65 |
-
writer.writerow(
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
if not pred.aspects:
|
| 68 |
-
writer.writerow(
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
db.commit()
|
| 71 |
processed_count += len(batch_texts)
|
| 72 |
-
|
| 73 |
if processed_count % 100 == 0 or processed_count == len(texts):
|
| 74 |
job.processed = processed_count
|
| 75 |
db.commit()
|
| 76 |
-
|
| 77 |
job.status = "completed"
|
| 78 |
job.completed_at = datetime.now(timezone.utc)
|
| 79 |
db.commit()
|
|
|
|
| 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):
|
| 13 |
db = SessionLocal()
|
|
|
|
| 15 |
job = db.query(BatchJob).filter(BatchJob.id == job_id).first()
|
| 16 |
if not job:
|
| 17 |
return
|
| 18 |
+
|
| 19 |
job.status = "processing"
|
| 20 |
db.commit()
|
| 21 |
|
|
|
|
| 23 |
df = pd.read_csv(file_path)
|
| 24 |
if "text" not in df.columns:
|
| 25 |
raise ValueError("CSV must contain a 'text' column.")
|
| 26 |
+
|
| 27 |
texts = df["text"].tolist()
|
| 28 |
batch_size = 32
|
| 29 |
+
|
| 30 |
results_dir = "data/results"
|
| 31 |
os.makedirs(results_dir, exist_ok=True)
|
| 32 |
result_file = f"{results_dir}/{job_id}.csv"
|
| 33 |
+
|
| 34 |
processed_count = 0
|
| 35 |
+
|
| 36 |
with open(result_file, "w", newline="", encoding="utf-8") as f:
|
| 37 |
writer = csv.writer(f)
|
| 38 |
+
writer.writerow(
|
| 39 |
+
[
|
| 40 |
+
"text",
|
| 41 |
+
"language",
|
| 42 |
+
"aspect",
|
| 43 |
+
"sentiment",
|
| 44 |
+
"confidence",
|
| 45 |
+
"start_pos",
|
| 46 |
+
"end_pos",
|
| 47 |
+
"processing_time_ms",
|
| 48 |
+
]
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
for i in range(0, len(texts), batch_size):
|
| 52 |
+
batch_texts = texts[i : i + batch_size]
|
| 53 |
predictions = pipeline.predict_batch(batch_texts)
|
| 54 |
+
|
| 55 |
for pred in predictions:
|
| 56 |
# Save Review
|
| 57 |
db_review = Review(
|
| 58 |
text=pred.text,
|
| 59 |
language=pred.language,
|
| 60 |
+
processing_time_ms=pred.processing_time_ms,
|
| 61 |
)
|
| 62 |
db.add(db_review)
|
| 63 |
db.commit()
|
| 64 |
db.refresh(db_review)
|
| 65 |
+
|
| 66 |
# Save Aspects & CSV
|
| 67 |
for asp in pred.aspects:
|
| 68 |
db_aspect = AspectResult(
|
|
|
|
| 71 |
sentiment=asp.sentiment,
|
| 72 |
confidence=asp.confidence,
|
| 73 |
start_pos=asp.start,
|
| 74 |
+
end_pos=asp.end,
|
| 75 |
)
|
| 76 |
db.add(db_aspect)
|
| 77 |
+
writer.writerow(
|
| 78 |
+
[
|
| 79 |
+
pred.text,
|
| 80 |
+
pred.language,
|
| 81 |
+
asp.aspect,
|
| 82 |
+
asp.sentiment,
|
| 83 |
+
asp.confidence,
|
| 84 |
+
asp.start,
|
| 85 |
+
asp.end,
|
| 86 |
+
pred.processing_time_ms,
|
| 87 |
+
]
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
if not pred.aspects:
|
| 91 |
+
writer.writerow(
|
| 92 |
+
[
|
| 93 |
+
pred.text,
|
| 94 |
+
pred.language,
|
| 95 |
+
"",
|
| 96 |
+
"",
|
| 97 |
+
"",
|
| 98 |
+
"",
|
| 99 |
+
"",
|
| 100 |
+
pred.processing_time_ms,
|
| 101 |
+
]
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
db.commit()
|
| 105 |
processed_count += len(batch_texts)
|
| 106 |
+
|
| 107 |
if processed_count % 100 == 0 or processed_count == len(texts):
|
| 108 |
job.processed = processed_count
|
| 109 |
db.commit()
|
| 110 |
+
|
| 111 |
job.status = "completed"
|
| 112 |
job.completed_at = datetime.now(timezone.utc)
|
| 113 |
db.commit()
|
src/data/augmentation.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
Script for cross-lingual data augmentation using back-translation.
|
| 3 |
Targets minority classes in Hindi data (negative and conflict).
|
| 4 |
"""
|
|
|
|
| 5 |
import json
|
| 6 |
import random
|
| 7 |
from pathlib import Path
|
|
@@ -12,56 +13,62 @@ import mlflow
|
|
| 12 |
|
| 13 |
random.seed(42)
|
| 14 |
|
|
|
|
| 15 |
class BackTranslator:
|
| 16 |
def __init__(self, src_lang="hi", pivot_lang="en"):
|
| 17 |
print(f"Loading translation models for {src_lang} <-> {pivot_lang}...")
|
| 18 |
self.hi2en_model_name = f"Helsinki-NLP/opus-mt-{src_lang}-{pivot_lang}"
|
| 19 |
self.en2hi_model_name = f"Helsinki-NLP/opus-mt-{pivot_lang}-{src_lang}"
|
| 20 |
-
|
| 21 |
self.hi2en_tokenizer = MarianTokenizer.from_pretrained(self.hi2en_model_name)
|
| 22 |
self.hi2en_model = MarianMTModel.from_pretrained(self.hi2en_model_name)
|
| 23 |
-
|
| 24 |
self.en2hi_tokenizer = MarianTokenizer.from_pretrained(self.en2hi_model_name)
|
| 25 |
self.en2hi_model = MarianMTModel.from_pretrained(self.en2hi_model_name)
|
| 26 |
-
|
| 27 |
def translate(self, texts, model, tokenizer):
|
| 28 |
inputs = tokenizer(texts, return_tensors="pt", padding=True, truncation=True)
|
| 29 |
with torch.no_grad():
|
| 30 |
translated = model.generate(**inputs)
|
| 31 |
return [tokenizer.decode(t, skip_special_tokens=True) for t in translated]
|
| 32 |
-
|
| 33 |
def back_translate(self, text):
|
| 34 |
-
en_translation = self.translate([text], self.hi2en_model, self.hi2en_tokenizer)[
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
return back_to_hi
|
| 37 |
|
|
|
|
| 38 |
def main():
|
| 39 |
data_dir = Path("data/processed")
|
| 40 |
input_file = data_dir / "hindi_train.jsonl"
|
| 41 |
output_file = data_dir / "hindi_augmented.jsonl"
|
| 42 |
-
|
| 43 |
if not input_file.exists():
|
| 44 |
print(f"Input file {input_file} not found. Ensure Phase 3 data is available.")
|
| 45 |
# Create a dummy augmented file to satisfy deliverables
|
| 46 |
output_file.parent.mkdir(parents=True, exist_ok=True)
|
| 47 |
-
with open(output_file,
|
| 48 |
json.dump({"text": "dummy", "sentiment": "negative"}, f)
|
| 49 |
-
f.write(
|
| 50 |
return
|
| 51 |
|
| 52 |
# Load original data
|
| 53 |
-
with open(input_file,
|
| 54 |
data = [json.loads(line) for line in f]
|
| 55 |
-
|
| 56 |
# Analyze class distribution
|
| 57 |
class_counts = Counter(item.get("sentiment") for item in data)
|
| 58 |
print("Original distribution:", class_counts)
|
| 59 |
-
|
| 60 |
translator = BackTranslator()
|
| 61 |
-
|
| 62 |
augmented_data = []
|
| 63 |
minority_classes = {"negative", "conflict"}
|
| 64 |
-
|
| 65 |
# Target: double the size of minority classes
|
| 66 |
for item in data:
|
| 67 |
sentiment = item.get("sentiment")
|
|
@@ -75,21 +82,21 @@ def main():
|
|
| 75 |
augmented_data.append(new_item)
|
| 76 |
except Exception as e:
|
| 77 |
print(f"Error translating text: {original_text} - {e}")
|
| 78 |
-
|
| 79 |
combined_data = data + augmented_data
|
| 80 |
-
|
| 81 |
new_class_counts = Counter(item.get("sentiment") for item in combined_data)
|
| 82 |
print("New distribution:", new_class_counts)
|
| 83 |
-
|
| 84 |
# Save output
|
| 85 |
output_file.parent.mkdir(parents=True, exist_ok=True)
|
| 86 |
-
with open(output_file,
|
| 87 |
for item in combined_data:
|
| 88 |
json.dump(item, f, ensure_ascii=False)
|
| 89 |
-
f.write(
|
| 90 |
-
|
| 91 |
print(f"Augmented dataset saved to {output_file}")
|
| 92 |
-
|
| 93 |
# Log to MLflow
|
| 94 |
mlflow.set_tracking_uri("sqlite:///mlflow.db")
|
| 95 |
mlflow.set_experiment("data-augmentation")
|
|
@@ -97,5 +104,6 @@ def main():
|
|
| 97 |
mlflow.log_dict(dict(class_counts), "original_class_distribution.json")
|
| 98 |
mlflow.log_dict(dict(new_class_counts), "augmented_class_distribution.json")
|
| 99 |
|
|
|
|
| 100 |
if __name__ == "__main__":
|
| 101 |
main()
|
|
|
|
| 2 |
Script for cross-lingual data augmentation using back-translation.
|
| 3 |
Targets minority classes in Hindi data (negative and conflict).
|
| 4 |
"""
|
| 5 |
+
|
| 6 |
import json
|
| 7 |
import random
|
| 8 |
from pathlib import Path
|
|
|
|
| 13 |
|
| 14 |
random.seed(42)
|
| 15 |
|
| 16 |
+
|
| 17 |
class BackTranslator:
|
| 18 |
def __init__(self, src_lang="hi", pivot_lang="en"):
|
| 19 |
print(f"Loading translation models for {src_lang} <-> {pivot_lang}...")
|
| 20 |
self.hi2en_model_name = f"Helsinki-NLP/opus-mt-{src_lang}-{pivot_lang}"
|
| 21 |
self.en2hi_model_name = f"Helsinki-NLP/opus-mt-{pivot_lang}-{src_lang}"
|
| 22 |
+
|
| 23 |
self.hi2en_tokenizer = MarianTokenizer.from_pretrained(self.hi2en_model_name)
|
| 24 |
self.hi2en_model = MarianMTModel.from_pretrained(self.hi2en_model_name)
|
| 25 |
+
|
| 26 |
self.en2hi_tokenizer = MarianTokenizer.from_pretrained(self.en2hi_model_name)
|
| 27 |
self.en2hi_model = MarianMTModel.from_pretrained(self.en2hi_model_name)
|
| 28 |
+
|
| 29 |
def translate(self, texts, model, tokenizer):
|
| 30 |
inputs = tokenizer(texts, return_tensors="pt", padding=True, truncation=True)
|
| 31 |
with torch.no_grad():
|
| 32 |
translated = model.generate(**inputs)
|
| 33 |
return [tokenizer.decode(t, skip_special_tokens=True) for t in translated]
|
| 34 |
+
|
| 35 |
def back_translate(self, text):
|
| 36 |
+
en_translation = self.translate([text], self.hi2en_model, self.hi2en_tokenizer)[
|
| 37 |
+
0
|
| 38 |
+
]
|
| 39 |
+
back_to_hi = self.translate(
|
| 40 |
+
[en_translation], self.en2hi_model, self.en2hi_tokenizer
|
| 41 |
+
)[0]
|
| 42 |
return back_to_hi
|
| 43 |
|
| 44 |
+
|
| 45 |
def main():
|
| 46 |
data_dir = Path("data/processed")
|
| 47 |
input_file = data_dir / "hindi_train.jsonl"
|
| 48 |
output_file = data_dir / "hindi_augmented.jsonl"
|
| 49 |
+
|
| 50 |
if not input_file.exists():
|
| 51 |
print(f"Input file {input_file} not found. Ensure Phase 3 data is available.")
|
| 52 |
# Create a dummy augmented file to satisfy deliverables
|
| 53 |
output_file.parent.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
with open(output_file, "w") as f:
|
| 55 |
json.dump({"text": "dummy", "sentiment": "negative"}, f)
|
| 56 |
+
f.write("\n")
|
| 57 |
return
|
| 58 |
|
| 59 |
# Load original data
|
| 60 |
+
with open(input_file, "r", encoding="utf-8") as f:
|
| 61 |
data = [json.loads(line) for line in f]
|
| 62 |
+
|
| 63 |
# Analyze class distribution
|
| 64 |
class_counts = Counter(item.get("sentiment") for item in data)
|
| 65 |
print("Original distribution:", class_counts)
|
| 66 |
+
|
| 67 |
translator = BackTranslator()
|
| 68 |
+
|
| 69 |
augmented_data = []
|
| 70 |
minority_classes = {"negative", "conflict"}
|
| 71 |
+
|
| 72 |
# Target: double the size of minority classes
|
| 73 |
for item in data:
|
| 74 |
sentiment = item.get("sentiment")
|
|
|
|
| 82 |
augmented_data.append(new_item)
|
| 83 |
except Exception as e:
|
| 84 |
print(f"Error translating text: {original_text} - {e}")
|
| 85 |
+
|
| 86 |
combined_data = data + augmented_data
|
| 87 |
+
|
| 88 |
new_class_counts = Counter(item.get("sentiment") for item in combined_data)
|
| 89 |
print("New distribution:", new_class_counts)
|
| 90 |
+
|
| 91 |
# Save output
|
| 92 |
output_file.parent.mkdir(parents=True, exist_ok=True)
|
| 93 |
+
with open(output_file, "w", encoding="utf-8") as f:
|
| 94 |
for item in combined_data:
|
| 95 |
json.dump(item, f, ensure_ascii=False)
|
| 96 |
+
f.write("\n")
|
| 97 |
+
|
| 98 |
print(f"Augmented dataset saved to {output_file}")
|
| 99 |
+
|
| 100 |
# Log to MLflow
|
| 101 |
mlflow.set_tracking_uri("sqlite:///mlflow.db")
|
| 102 |
mlflow.set_experiment("data-augmentation")
|
|
|
|
| 104 |
mlflow.log_dict(dict(class_counts), "original_class_distribution.json")
|
| 105 |
mlflow.log_dict(dict(new_class_counts), "augmented_class_distribution.json")
|
| 106 |
|
| 107 |
+
|
| 108 |
if __name__ == "__main__":
|
| 109 |
main()
|
src/data/bio_tagger.py
CHANGED
|
@@ -1,86 +1,98 @@
|
|
| 1 |
import re
|
| 2 |
from typing import List, Dict, Any, Tuple
|
| 3 |
|
|
|
|
| 4 |
def tokenize(text: str) -> List[Tuple[str, int, int]]:
|
| 5 |
"""Tokenizes text by words, returning tokens and their start/end character offsets.
|
| 6 |
-
|
| 7 |
Uses simple regex based tokenization to preserve whitespace semantics for BIO tagging.
|
| 8 |
-
|
| 9 |
Args:
|
| 10 |
text: The input text string to tokenize.
|
| 11 |
-
|
| 12 |
Returns:
|
| 13 |
List of tuples containing (token_string, start_offset, end_offset).
|
| 14 |
"""
|
| 15 |
tokens = []
|
| 16 |
# Match non-whitespace characters
|
| 17 |
-
for match in re.finditer(r
|
| 18 |
tokens.append((match.group(), match.start(), match.end()))
|
| 19 |
return tokens
|
| 20 |
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
| 22 |
"""
|
| 23 |
Converts text and aspect spans to BIO tagged tokens.
|
| 24 |
-
|
| 25 |
Args:
|
| 26 |
text: The input review string.
|
| 27 |
aspect_terms: List of dictionaries with 'term', 'from', and 'to' keys.
|
| 28 |
-
|
| 29 |
Returns:
|
| 30 |
List of dictionaries with 'token' and 'label' (B-ASP, I-ASP, O).
|
| 31 |
"""
|
| 32 |
tokens = tokenize(text)
|
| 33 |
-
|
| 34 |
# Sort aspects by start index
|
| 35 |
-
sorted_aspects = sorted(aspect_terms, key=lambda x: x[
|
| 36 |
-
|
| 37 |
-
bio_tags = []
|
| 38 |
aspect_idx = 0
|
| 39 |
num_aspects = len(sorted_aspects)
|
| 40 |
-
|
| 41 |
for token_str, t_start, t_end in tokens:
|
| 42 |
label = "O"
|
| 43 |
-
|
| 44 |
# Move aspect pointer if we've passed the current aspect completely
|
| 45 |
-
while aspect_idx < num_aspects and sorted_aspects[aspect_idx][
|
| 46 |
aspect_idx += 1
|
| 47 |
-
|
| 48 |
if aspect_idx < num_aspects:
|
| 49 |
curr_aspect = sorted_aspects[aspect_idx]
|
| 50 |
-
a_start = curr_aspect[
|
| 51 |
-
a_end = curr_aspect[
|
| 52 |
-
|
| 53 |
# Check overlap
|
| 54 |
if not (t_end <= a_start or t_start >= a_end):
|
| 55 |
# There is overlap
|
| 56 |
# If this token overlaps with the start of the aspect
|
| 57 |
-
if t_start <= a_start or (
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
else:
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
| 66 |
bio_tags.append({"token": token_str, "label": label})
|
| 67 |
-
|
| 68 |
return bio_tags
|
| 69 |
|
|
|
|
| 70 |
def bio_to_aspects(tokens: List[str], labels: List[str]) -> List[str]:
|
| 71 |
"""
|
| 72 |
Converts BIO tags back to a list of aspect terms.
|
| 73 |
-
|
| 74 |
Args:
|
| 75 |
tokens: List of string tokens.
|
| 76 |
labels: List of BIO labels corresponding to the tokens.
|
| 77 |
-
|
| 78 |
Returns:
|
| 79 |
List of extracted aspect term strings.
|
| 80 |
"""
|
| 81 |
aspects = []
|
| 82 |
-
current_aspect = []
|
| 83 |
-
|
| 84 |
for token, label in zip(tokens, labels):
|
| 85 |
if label == "B-ASP":
|
| 86 |
if current_aspect:
|
|
@@ -92,12 +104,12 @@ def bio_to_aspects(tokens: List[str], labels: List[str]) -> List[str]:
|
|
| 92 |
else:
|
| 93 |
# Invalid sequence (I-ASP without B-ASP), treat as B-ASP
|
| 94 |
current_aspect = [token]
|
| 95 |
-
else:
|
| 96 |
if current_aspect:
|
| 97 |
aspects.append(" ".join(current_aspect))
|
| 98 |
current_aspect = []
|
| 99 |
-
|
| 100 |
if current_aspect:
|
| 101 |
aspects.append(" ".join(current_aspect))
|
| 102 |
-
|
| 103 |
return aspects
|
|
|
|
| 1 |
import re
|
| 2 |
from typing import List, Dict, Any, Tuple
|
| 3 |
|
| 4 |
+
|
| 5 |
def tokenize(text: str) -> List[Tuple[str, int, int]]:
|
| 6 |
"""Tokenizes text by words, returning tokens and their start/end character offsets.
|
| 7 |
+
|
| 8 |
Uses simple regex based tokenization to preserve whitespace semantics for BIO tagging.
|
| 9 |
+
|
| 10 |
Args:
|
| 11 |
text: The input text string to tokenize.
|
| 12 |
+
|
| 13 |
Returns:
|
| 14 |
List of tuples containing (token_string, start_offset, end_offset).
|
| 15 |
"""
|
| 16 |
tokens = []
|
| 17 |
# Match non-whitespace characters
|
| 18 |
+
for match in re.finditer(r"\S+", text):
|
| 19 |
tokens.append((match.group(), match.start(), match.end()))
|
| 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 |
+
|
| 29 |
Args:
|
| 30 |
text: The input review string.
|
| 31 |
aspect_terms: List of dictionaries with 'term', 'from', and 'to' keys.
|
| 32 |
+
|
| 33 |
Returns:
|
| 34 |
List of dictionaries with 'token' and 'label' (B-ASP, I-ASP, O).
|
| 35 |
"""
|
| 36 |
tokens = tokenize(text)
|
| 37 |
+
|
| 38 |
# Sort aspects by start index
|
| 39 |
+
sorted_aspects = sorted(aspect_terms, key=lambda x: x["from"])
|
| 40 |
+
|
| 41 |
+
bio_tags: List[Dict[str, Any]] = []
|
| 42 |
aspect_idx = 0
|
| 43 |
num_aspects = len(sorted_aspects)
|
| 44 |
+
|
| 45 |
for token_str, t_start, t_end in tokens:
|
| 46 |
label = "O"
|
| 47 |
+
|
| 48 |
# Move aspect pointer if we've passed the current aspect completely
|
| 49 |
+
while aspect_idx < num_aspects and sorted_aspects[aspect_idx]["to"] <= t_start:
|
| 50 |
aspect_idx += 1
|
| 51 |
+
|
| 52 |
if aspect_idx < num_aspects:
|
| 53 |
curr_aspect = sorted_aspects[aspect_idx]
|
| 54 |
+
a_start = curr_aspect["from"]
|
| 55 |
+
a_end = curr_aspect["to"]
|
| 56 |
+
|
| 57 |
# Check overlap
|
| 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
|
| 69 |
+
if len(bio_tags) > 0 and bio_tags[-1]["label"] in (
|
| 70 |
+
"B-ASP",
|
| 71 |
+
"I-ASP",
|
| 72 |
+
):
|
| 73 |
+
label = "I-ASP"
|
| 74 |
+
else:
|
| 75 |
+
label = "B-ASP"
|
| 76 |
+
|
| 77 |
bio_tags.append({"token": token_str, "label": label})
|
| 78 |
+
|
| 79 |
return bio_tags
|
| 80 |
|
| 81 |
+
|
| 82 |
def bio_to_aspects(tokens: List[str], labels: List[str]) -> List[str]:
|
| 83 |
"""
|
| 84 |
Converts BIO tags back to a list of aspect terms.
|
| 85 |
+
|
| 86 |
Args:
|
| 87 |
tokens: List of string tokens.
|
| 88 |
labels: List of BIO labels corresponding to the tokens.
|
| 89 |
+
|
| 90 |
Returns:
|
| 91 |
List of extracted aspect term strings.
|
| 92 |
"""
|
| 93 |
aspects = []
|
| 94 |
+
current_aspect: List[str] = []
|
| 95 |
+
|
| 96 |
for token, label in zip(tokens, labels):
|
| 97 |
if label == "B-ASP":
|
| 98 |
if current_aspect:
|
|
|
|
| 104 |
else:
|
| 105 |
# Invalid sequence (I-ASP without B-ASP), treat as B-ASP
|
| 106 |
current_aspect = [token]
|
| 107 |
+
else: # O
|
| 108 |
if current_aspect:
|
| 109 |
aspects.append(" ".join(current_aspect))
|
| 110 |
current_aspect = []
|
| 111 |
+
|
| 112 |
if current_aspect:
|
| 113 |
aspects.append(" ".join(current_aspect))
|
| 114 |
+
|
| 115 |
return aspects
|
src/data/dataset.py
CHANGED
|
@@ -1,39 +1,41 @@
|
|
| 1 |
import json
|
| 2 |
-
from pathlib import Path
|
| 3 |
from datasets import load_from_disk
|
| 4 |
from collections import defaultdict
|
| 5 |
from src.utils.config import RAW_DIR, SEMEVAL_TRAIN_PATH, SEMEVAL_TEST_PATH
|
| 6 |
from src.data.preprocess import clean
|
| 7 |
from src.data.lang_detect import detect_language
|
| 8 |
|
|
|
|
| 9 |
def process_semeval():
|
| 10 |
rest_path = RAW_DIR / "semeval_restaurants"
|
| 11 |
lap_path = RAW_DIR / "semeval_laptops"
|
| 12 |
-
|
| 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 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
| 23 |
target = train_samples if ds_name == "train" else test_samples
|
| 24 |
for row in ds:
|
| 25 |
text = row["text"]
|
| 26 |
span = row["span"]
|
| 27 |
label = row["label"]
|
| 28 |
-
|
| 29 |
-
target[(text, source_name)].append({
|
| 30 |
-
|
| 31 |
-
"polarity": label
|
| 32 |
-
})
|
| 33 |
-
|
| 34 |
SEMEVAL_TRAIN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 35 |
-
|
| 36 |
-
for path, data_dict in [
|
|
|
|
|
|
|
|
|
|
| 37 |
total = 0
|
| 38 |
lang_counts = defaultdict(int)
|
| 39 |
with open(path, "w", encoding="utf-8") as f:
|
|
@@ -41,30 +43,33 @@ def process_semeval():
|
|
| 41 |
lang = detect_language(text)
|
| 42 |
cleaned_text = clean(text, lang)
|
| 43 |
lang_counts[lang] += 1
|
| 44 |
-
|
| 45 |
final_aspects = []
|
| 46 |
for aspect in aspects:
|
| 47 |
term_clean = clean(aspect["term"], lang)
|
| 48 |
from_idx = cleaned_text.find(term_clean)
|
| 49 |
to_idx = from_idx + len(term_clean) if from_idx != -1 else -1
|
| 50 |
-
final_aspects.append(
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
| 57 |
sample = {
|
| 58 |
"text": cleaned_text,
|
| 59 |
"language": lang,
|
| 60 |
"aspect_terms": final_aspects,
|
| 61 |
-
"source": source
|
| 62 |
}
|
| 63 |
f.write(json.dumps(sample, ensure_ascii=False) + "\n")
|
| 64 |
total += 1
|
| 65 |
-
|
| 66 |
print(f"SemEval {path.stem} samples: {total}")
|
| 67 |
print(f"SemEval {path.stem} languages: {dict(lang_counts)}")
|
| 68 |
|
|
|
|
| 69 |
if __name__ == "__main__":
|
| 70 |
process_semeval()
|
|
|
|
| 1 |
import json
|
|
|
|
| 2 |
from datasets import load_from_disk
|
| 3 |
from collections import defaultdict
|
| 4 |
from src.utils.config import RAW_DIR, SEMEVAL_TRAIN_PATH, SEMEVAL_TEST_PATH
|
| 5 |
from src.data.preprocess import clean
|
| 6 |
from src.data.lang_detect import detect_language
|
| 7 |
|
| 8 |
+
|
| 9 |
def process_semeval():
|
| 10 |
rest_path = RAW_DIR / "semeval_restaurants"
|
| 11 |
lap_path = RAW_DIR / "semeval_laptops"
|
| 12 |
+
|
| 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"),
|
| 21 |
+
("test", rest_data["test"], "restaurants"),
|
| 22 |
+
("train", lap_data["train"], "laptops"),
|
| 23 |
+
("test", lap_data["test"], "laptops"),
|
| 24 |
+
]:
|
| 25 |
target = train_samples if ds_name == "train" else test_samples
|
| 26 |
for row in ds:
|
| 27 |
text = row["text"]
|
| 28 |
span = row["span"]
|
| 29 |
label = row["label"]
|
| 30 |
+
|
| 31 |
+
target[(text, source_name)].append({"term": span, "polarity": label})
|
| 32 |
+
|
|
|
|
|
|
|
|
|
|
| 33 |
SEMEVAL_TRAIN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
|
| 35 |
+
for path, data_dict in [
|
| 36 |
+
(SEMEVAL_TRAIN_PATH, train_samples),
|
| 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:
|
|
|
|
| 43 |
lang = detect_language(text)
|
| 44 |
cleaned_text = clean(text, lang)
|
| 45 |
lang_counts[lang] += 1
|
| 46 |
+
|
| 47 |
final_aspects = []
|
| 48 |
for aspect in aspects:
|
| 49 |
term_clean = clean(aspect["term"], lang)
|
| 50 |
from_idx = cleaned_text.find(term_clean)
|
| 51 |
to_idx = from_idx + len(term_clean) if from_idx != -1 else -1
|
| 52 |
+
final_aspects.append(
|
| 53 |
+
{
|
| 54 |
+
"term": term_clean,
|
| 55 |
+
"polarity": aspect["polarity"],
|
| 56 |
+
"from": from_idx,
|
| 57 |
+
"to": to_idx,
|
| 58 |
+
}
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
sample = {
|
| 62 |
"text": cleaned_text,
|
| 63 |
"language": lang,
|
| 64 |
"aspect_terms": final_aspects,
|
| 65 |
+
"source": source,
|
| 66 |
}
|
| 67 |
f.write(json.dumps(sample, ensure_ascii=False) + "\n")
|
| 68 |
total += 1
|
| 69 |
+
|
| 70 |
print(f"SemEval {path.stem} samples: {total}")
|
| 71 |
print(f"SemEval {path.stem} languages: {dict(lang_counts)}")
|
| 72 |
|
| 73 |
+
|
| 74 |
if __name__ == "__main__":
|
| 75 |
process_semeval()
|
src/data/hf_dataset.py
CHANGED
|
@@ -2,7 +2,7 @@ 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
|
|
@@ -10,59 +10,67 @@ from src.data.bio_tagger import convert_to_bio
|
|
| 10 |
|
| 11 |
np.random.seed(42)
|
| 12 |
|
|
|
|
| 13 |
def load_data(file_paths: List[Path]) -> List[Dict[str, Any]]:
|
| 14 |
data = []
|
| 15 |
for path in file_paths:
|
| 16 |
-
with open(path,
|
| 17 |
for line in f:
|
| 18 |
if line.strip():
|
| 19 |
data.append(json.loads(line))
|
| 20 |
return data
|
| 21 |
|
|
|
|
| 22 |
def prepare_ner_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 23 |
"""Prepares data for Token Classification (NER)."""
|
| 24 |
-
ner_data = []
|
| 25 |
label_map = {"O": 0, "B-ASP": 1, "I-ASP": 2}
|
| 26 |
-
|
| 27 |
for item in data:
|
| 28 |
-
text = item[
|
| 29 |
-
aspects = item.get(
|
| 30 |
bio_tags = convert_to_bio(text, aspects)
|
| 31 |
-
|
| 32 |
-
tokens = [t[
|
| 33 |
-
ner_tags = [label_map[t[
|
| 34 |
-
|
| 35 |
-
ner_data.append(
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
return ner_data
|
| 41 |
|
|
|
|
| 42 |
def prepare_cls_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 43 |
"""Prepares data for Sequence Classification (Sentiment)."""
|
| 44 |
-
cls_data = []
|
| 45 |
sentiment_map = {"positive": 0, "negative": 1, "neutral": 2, "conflict": 3}
|
| 46 |
-
|
| 47 |
for item in data:
|
| 48 |
-
text = item[
|
| 49 |
-
aspects = item.get(
|
| 50 |
-
|
| 51 |
for aspect in aspects:
|
| 52 |
-
term = aspect[
|
| 53 |
-
polarity = aspect[
|
| 54 |
-
|
| 55 |
if polarity not in sentiment_map:
|
| 56 |
continue
|
| 57 |
-
|
| 58 |
-
cls_data.append(
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
return cls_data
|
| 65 |
|
|
|
|
| 66 |
def align_labels_with_tokens(labels, word_ids):
|
| 67 |
new_labels = []
|
| 68 |
current_word = None
|
|
@@ -76,62 +84,76 @@ def align_labels_with_tokens(labels, word_ids):
|
|
| 76 |
new_labels.append(-100)
|
| 77 |
return new_labels
|
| 78 |
|
|
|
|
| 79 |
def main():
|
| 80 |
data_dir = Path("data/processed")
|
| 81 |
output_dir = Path("data/tokenized")
|
| 82 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 83 |
-
|
| 84 |
# Load all English SemEval data
|
| 85 |
train_path = data_dir / "semeval_train.jsonl"
|
| 86 |
test_path = data_dir / "semeval_test.jsonl"
|
| 87 |
all_data = load_data([train_path, test_path])
|
| 88 |
-
|
| 89 |
# Prepare datasets
|
| 90 |
ner_data = prepare_ner_data(all_data)
|
| 91 |
cls_data = prepare_cls_data(all_data)
|
| 92 |
-
|
| 93 |
tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
|
| 94 |
-
|
| 95 |
# ---------------------------------------------------------
|
| 96 |
# 1. Token Classification (NER) Dataset
|
| 97 |
# ---------------------------------------------------------
|
| 98 |
ner_df = pd.DataFrame(ner_data)
|
| 99 |
-
|
| 100 |
# Split 80/10/10
|
| 101 |
# For NER, we don't have a single sentiment to stratify on easily, so random split
|
| 102 |
train_ner, temp_ner = train_test_split(ner_df, test_size=0.2, random_state=42)
|
| 103 |
val_ner, test_ner = train_test_split(temp_ner, test_size=0.5, random_state=42)
|
| 104 |
-
|
| 105 |
def tokenize_and_align_labels(examples):
|
| 106 |
tokenized_inputs = tokenizer(
|
| 107 |
-
examples["tokens"],
|
|
|
|
|
|
|
|
|
|
| 108 |
)
|
| 109 |
labels = []
|
| 110 |
-
for i, label in enumerate(examples[
|
| 111 |
word_ids = tokenized_inputs.word_ids(batch_index=i)
|
| 112 |
labels.append(align_labels_with_tokens(label, word_ids))
|
| 113 |
tokenized_inputs["labels"] = labels
|
| 114 |
return tokenized_inputs
|
| 115 |
-
|
| 116 |
-
ner_dataset = DatasetDict(
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
tokenized_ner.save_to_disk(str(output_dir / "absa_ner_dataset"))
|
| 124 |
print(f"NER Dataset saved to {output_dir / 'absa_ner_dataset'}")
|
| 125 |
-
|
| 126 |
# ---------------------------------------------------------
|
| 127 |
# 2. Sequence Classification (Sentiment) Dataset
|
| 128 |
# ---------------------------------------------------------
|
| 129 |
cls_df = pd.DataFrame(cls_data)
|
| 130 |
-
|
| 131 |
# Stratified split 80/10/10 based on label
|
| 132 |
-
train_cls, temp_cls = train_test_split(
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
def tokenize_cls(examples):
|
| 136 |
# Format: [CLS] text [SEP] aspect_term [SEP]
|
| 137 |
return tokenizer(
|
|
@@ -139,18 +161,23 @@ def main():
|
|
| 139 |
examples["aspect_term"],
|
| 140 |
truncation=True,
|
| 141 |
max_length=128,
|
| 142 |
-
padding=False
|
| 143 |
)
|
| 144 |
-
|
| 145 |
-
cls_dataset = DatasetDict(
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
tokenized_cls.save_to_disk(str(output_dir / "absa_cls_dataset"))
|
| 153 |
print(f"CLS Dataset saved to {output_dir / 'absa_cls_dataset'}")
|
| 154 |
|
|
|
|
| 155 |
if __name__ == "__main__":
|
| 156 |
main()
|
|
|
|
| 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
|
|
|
|
| 10 |
|
| 11 |
np.random.seed(42)
|
| 12 |
|
| 13 |
+
|
| 14 |
def load_data(file_paths: List[Path]) -> List[Dict[str, Any]]:
|
| 15 |
data = []
|
| 16 |
for path in file_paths:
|
| 17 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 18 |
for line in f:
|
| 19 |
if line.strip():
|
| 20 |
data.append(json.loads(line))
|
| 21 |
return data
|
| 22 |
|
| 23 |
+
|
| 24 |
def prepare_ner_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 25 |
"""Prepares data for Token Classification (NER)."""
|
| 26 |
+
ner_data: List[Dict[str, Any]] = []
|
| 27 |
label_map = {"O": 0, "B-ASP": 1, "I-ASP": 2}
|
| 28 |
+
|
| 29 |
for item in data:
|
| 30 |
+
text = item["text"]
|
| 31 |
+
aspects = item.get("aspect_terms", [])
|
| 32 |
bio_tags = convert_to_bio(text, aspects)
|
| 33 |
+
|
| 34 |
+
tokens = [t["token"] for t in bio_tags]
|
| 35 |
+
ner_tags = [label_map[t["label"]] for t in bio_tags]
|
| 36 |
+
|
| 37 |
+
ner_data.append(
|
| 38 |
+
{
|
| 39 |
+
"tokens": tokens,
|
| 40 |
+
"ner_tags": ner_tags,
|
| 41 |
+
"id": item.get("id", str(len(ner_data))),
|
| 42 |
+
}
|
| 43 |
+
)
|
| 44 |
return ner_data
|
| 45 |
|
| 46 |
+
|
| 47 |
def prepare_cls_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 48 |
"""Prepares data for Sequence Classification (Sentiment)."""
|
| 49 |
+
cls_data: List[Dict[str, Any]] = []
|
| 50 |
sentiment_map = {"positive": 0, "negative": 1, "neutral": 2, "conflict": 3}
|
| 51 |
+
|
| 52 |
for item in data:
|
| 53 |
+
text = item["text"]
|
| 54 |
+
aspects = item.get("aspect_terms", [])
|
| 55 |
+
|
| 56 |
for aspect in aspects:
|
| 57 |
+
term = aspect["term"]
|
| 58 |
+
polarity = aspect["polarity"]
|
| 59 |
+
|
| 60 |
if polarity not in sentiment_map:
|
| 61 |
continue
|
| 62 |
+
|
| 63 |
+
cls_data.append(
|
| 64 |
+
{
|
| 65 |
+
"text": text,
|
| 66 |
+
"aspect_term": term,
|
| 67 |
+
"label": sentiment_map[polarity],
|
| 68 |
+
"id": f"{item.get('id', str(len(cls_data)))}_{term}",
|
| 69 |
+
}
|
| 70 |
+
)
|
| 71 |
return cls_data
|
| 72 |
|
| 73 |
+
|
| 74 |
def align_labels_with_tokens(labels, word_ids):
|
| 75 |
new_labels = []
|
| 76 |
current_word = None
|
|
|
|
| 84 |
new_labels.append(-100)
|
| 85 |
return new_labels
|
| 86 |
|
| 87 |
+
|
| 88 |
def main():
|
| 89 |
data_dir = Path("data/processed")
|
| 90 |
output_dir = Path("data/tokenized")
|
| 91 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 92 |
+
|
| 93 |
# Load all English SemEval data
|
| 94 |
train_path = data_dir / "semeval_train.jsonl"
|
| 95 |
test_path = data_dir / "semeval_test.jsonl"
|
| 96 |
all_data = load_data([train_path, test_path])
|
| 97 |
+
|
| 98 |
# Prepare datasets
|
| 99 |
ner_data = prepare_ner_data(all_data)
|
| 100 |
cls_data = prepare_cls_data(all_data)
|
| 101 |
+
|
| 102 |
tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
|
| 103 |
+
|
| 104 |
# ---------------------------------------------------------
|
| 105 |
# 1. Token Classification (NER) Dataset
|
| 106 |
# ---------------------------------------------------------
|
| 107 |
ner_df = pd.DataFrame(ner_data)
|
| 108 |
+
|
| 109 |
# Split 80/10/10
|
| 110 |
# For NER, we don't have a single sentiment to stratify on easily, so random split
|
| 111 |
train_ner, temp_ner = train_test_split(ner_df, test_size=0.2, random_state=42)
|
| 112 |
val_ner, test_ner = train_test_split(temp_ner, test_size=0.5, random_state=42)
|
| 113 |
+
|
| 114 |
def tokenize_and_align_labels(examples):
|
| 115 |
tokenized_inputs = tokenizer(
|
| 116 |
+
examples["tokens"],
|
| 117 |
+
truncation=True,
|
| 118 |
+
is_split_into_words=True,
|
| 119 |
+
max_length=128,
|
| 120 |
)
|
| 121 |
labels = []
|
| 122 |
+
for i, label in enumerate(examples["ner_tags"]):
|
| 123 |
word_ids = tokenized_inputs.word_ids(batch_index=i)
|
| 124 |
labels.append(align_labels_with_tokens(label, word_ids))
|
| 125 |
tokenized_inputs["labels"] = labels
|
| 126 |
return tokenized_inputs
|
| 127 |
+
|
| 128 |
+
ner_dataset = DatasetDict(
|
| 129 |
+
{
|
| 130 |
+
"train": Dataset.from_pandas(train_ner, preserve_index=False),
|
| 131 |
+
"validation": Dataset.from_pandas(val_ner, preserve_index=False),
|
| 132 |
+
"test": Dataset.from_pandas(test_ner, preserve_index=False),
|
| 133 |
+
}
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
tokenized_ner = ner_dataset.map(
|
| 137 |
+
tokenize_and_align_labels,
|
| 138 |
+
batched=True,
|
| 139 |
+
remove_columns=["tokens", "ner_tags", "id"],
|
| 140 |
+
)
|
| 141 |
tokenized_ner.save_to_disk(str(output_dir / "absa_ner_dataset"))
|
| 142 |
print(f"NER Dataset saved to {output_dir / 'absa_ner_dataset'}")
|
| 143 |
+
|
| 144 |
# ---------------------------------------------------------
|
| 145 |
# 2. Sequence Classification (Sentiment) Dataset
|
| 146 |
# ---------------------------------------------------------
|
| 147 |
cls_df = pd.DataFrame(cls_data)
|
| 148 |
+
|
| 149 |
# Stratified split 80/10/10 based on label
|
| 150 |
+
train_cls, temp_cls = train_test_split(
|
| 151 |
+
cls_df, test_size=0.2, random_state=42, stratify=cls_df["label"]
|
| 152 |
+
)
|
| 153 |
+
val_cls, test_cls = train_test_split(
|
| 154 |
+
temp_cls, test_size=0.5, random_state=42, stratify=temp_cls["label"]
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
def tokenize_cls(examples):
|
| 158 |
# Format: [CLS] text [SEP] aspect_term [SEP]
|
| 159 |
return tokenizer(
|
|
|
|
| 161 |
examples["aspect_term"],
|
| 162 |
truncation=True,
|
| 163 |
max_length=128,
|
| 164 |
+
padding=False,
|
| 165 |
)
|
| 166 |
+
|
| 167 |
+
cls_dataset = DatasetDict(
|
| 168 |
+
{
|
| 169 |
+
"train": Dataset.from_pandas(train_cls, preserve_index=False),
|
| 170 |
+
"validation": Dataset.from_pandas(val_cls, preserve_index=False),
|
| 171 |
+
"test": Dataset.from_pandas(test_cls, preserve_index=False),
|
| 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 |
|
| 181 |
+
|
| 182 |
if __name__ == "__main__":
|
| 183 |
main()
|
src/data/hindi_loader.py
CHANGED
|
@@ -1,28 +1,29 @@
|
|
| 1 |
import json
|
| 2 |
-
from pathlib import Path
|
| 3 |
from src.utils.config import RAW_DIR, AMAZON_HINDI_PATH
|
| 4 |
from src.data.preprocess import clean
|
| 5 |
from src.data.lang_detect import detect_language
|
| 6 |
|
|
|
|
| 7 |
def process_hindi():
|
| 8 |
raw_path = RAW_DIR / "amazon_hindi" / "hindi_sentiment.jsonl"
|
| 9 |
if not raw_path.exists():
|
| 10 |
print(f"File not found: {raw_path}")
|
| 11 |
return
|
| 12 |
-
|
| 13 |
AMAZON_HINDI_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 14 |
-
|
| 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,
|
| 19 |
-
|
|
|
|
| 20 |
for line in fin:
|
| 21 |
row = json.loads(line)
|
| 22 |
text = row.get("INDIC REVIEW", row.get("text", ""))
|
| 23 |
if not text:
|
| 24 |
continue
|
| 25 |
-
|
| 26 |
label = str(row.get("LABEL", row.get("label", ""))).lower()
|
| 27 |
if label == "0" or label == "negative":
|
| 28 |
label = "negative"
|
|
@@ -30,26 +31,27 @@ def process_hindi():
|
|
| 30 |
label = "positive"
|
| 31 |
else:
|
| 32 |
label = "neutral"
|
| 33 |
-
|
| 34 |
lang = detect_language(text)
|
| 35 |
if lang in lang_counts:
|
| 36 |
lang_counts[lang] += 1
|
| 37 |
else:
|
| 38 |
lang_counts[lang] = 1
|
| 39 |
-
|
| 40 |
cleaned_text = clean(text, lang)
|
| 41 |
-
|
| 42 |
sample = {
|
| 43 |
"text": cleaned_text,
|
| 44 |
"language": lang,
|
| 45 |
"label": label,
|
| 46 |
-
"source": "amazon_hindi"
|
| 47 |
}
|
| 48 |
fout.write(json.dumps(sample, ensure_ascii=False) + "\n")
|
| 49 |
total += 1
|
| 50 |
-
|
| 51 |
print(f"Hindi samples processed: {total}")
|
| 52 |
print(f"Hindi language distribution: {lang_counts}")
|
| 53 |
|
|
|
|
| 54 |
if __name__ == "__main__":
|
| 55 |
process_hindi()
|
|
|
|
| 1 |
import json
|
|
|
|
| 2 |
from src.utils.config import RAW_DIR, AMAZON_HINDI_PATH
|
| 3 |
from src.data.preprocess import clean
|
| 4 |
from src.data.lang_detect import detect_language
|
| 5 |
|
| 6 |
+
|
| 7 |
def process_hindi():
|
| 8 |
raw_path = RAW_DIR / "amazon_hindi" / "hindi_sentiment.jsonl"
|
| 9 |
if not raw_path.exists():
|
| 10 |
print(f"File not found: {raw_path}")
|
| 11 |
return
|
| 12 |
+
|
| 13 |
AMAZON_HINDI_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 14 |
+
|
| 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", ""))
|
| 24 |
if not text:
|
| 25 |
continue
|
| 26 |
+
|
| 27 |
label = str(row.get("LABEL", row.get("label", ""))).lower()
|
| 28 |
if label == "0" or label == "negative":
|
| 29 |
label = "negative"
|
|
|
|
| 31 |
label = "positive"
|
| 32 |
else:
|
| 33 |
label = "neutral"
|
| 34 |
+
|
| 35 |
lang = detect_language(text)
|
| 36 |
if lang in lang_counts:
|
| 37 |
lang_counts[lang] += 1
|
| 38 |
else:
|
| 39 |
lang_counts[lang] = 1
|
| 40 |
+
|
| 41 |
cleaned_text = clean(text, lang)
|
| 42 |
+
|
| 43 |
sample = {
|
| 44 |
"text": cleaned_text,
|
| 45 |
"language": lang,
|
| 46 |
"label": label,
|
| 47 |
+
"source": "amazon_hindi",
|
| 48 |
}
|
| 49 |
fout.write(json.dumps(sample, ensure_ascii=False) + "\n")
|
| 50 |
total += 1
|
| 51 |
+
|
| 52 |
print(f"Hindi samples processed: {total}")
|
| 53 |
print(f"Hindi language distribution: {lang_counts}")
|
| 54 |
|
| 55 |
+
|
| 56 |
if __name__ == "__main__":
|
| 57 |
process_hindi()
|
src/data/lang_detect.py
CHANGED
|
@@ -1,46 +1,46 @@
|
|
| 1 |
import re
|
| 2 |
import fasttext
|
| 3 |
-
import os
|
| 4 |
from src.utils.config import FASTTEXT_MODEL_PATH
|
| 5 |
-
from pathlib import Path
|
| 6 |
|
| 7 |
_model = None
|
| 8 |
|
|
|
|
| 9 |
def get_model():
|
| 10 |
global _model
|
| 11 |
if _model is None:
|
| 12 |
_model = fasttext.load_model(str(FASTTEXT_MODEL_PATH))
|
| 13 |
return _model
|
| 14 |
|
|
|
|
| 15 |
def detect_language(text: str) -> str:
|
| 16 |
"""Detects if text is en, hi, hinglish, or other.
|
| 17 |
-
|
| 18 |
Args:
|
| 19 |
text: The text to analyze.
|
| 20 |
-
|
| 21 |
Returns:
|
| 22 |
String representing language code ('en', 'hi', 'hinglish', or 'other').
|
| 23 |
"""
|
| 24 |
if not text or not text.strip():
|
| 25 |
return "other"
|
| 26 |
-
|
| 27 |
-
has_alpha = bool(re.search(r
|
| 28 |
if not has_alpha:
|
| 29 |
return "other"
|
| 30 |
-
|
| 31 |
-
text = text.replace(
|
| 32 |
model = get_model()
|
| 33 |
predictions = model.predict(text, k=1)
|
| 34 |
label = predictions[0][0].replace("__label__", "")
|
| 35 |
-
|
| 36 |
-
has_latin = bool(re.search(r
|
| 37 |
-
has_devanagari = bool(re.search(r
|
| 38 |
-
|
| 39 |
is_hinglish = has_latin and has_devanagari
|
| 40 |
-
|
| 41 |
if is_hinglish and label in ["en", "hi"]:
|
| 42 |
return "hinglish"
|
| 43 |
-
|
| 44 |
if label == "en":
|
| 45 |
return "en"
|
| 46 |
elif label == "hi":
|
|
|
|
| 1 |
import re
|
| 2 |
import fasttext
|
|
|
|
| 3 |
from src.utils.config import FASTTEXT_MODEL_PATH
|
|
|
|
| 4 |
|
| 5 |
_model = None
|
| 6 |
|
| 7 |
+
|
| 8 |
def get_model():
|
| 9 |
global _model
|
| 10 |
if _model is None:
|
| 11 |
_model = fasttext.load_model(str(FASTTEXT_MODEL_PATH))
|
| 12 |
return _model
|
| 13 |
|
| 14 |
+
|
| 15 |
def detect_language(text: str) -> str:
|
| 16 |
"""Detects if text is en, hi, hinglish, or other.
|
| 17 |
+
|
| 18 |
Args:
|
| 19 |
text: The text to analyze.
|
| 20 |
+
|
| 21 |
Returns:
|
| 22 |
String representing language code ('en', 'hi', 'hinglish', or 'other').
|
| 23 |
"""
|
| 24 |
if not text or not text.strip():
|
| 25 |
return "other"
|
| 26 |
+
|
| 27 |
+
has_alpha = bool(re.search(r"[^\W\d_]", text))
|
| 28 |
if not has_alpha:
|
| 29 |
return "other"
|
| 30 |
+
|
| 31 |
+
text = text.replace("\n", " ")
|
| 32 |
model = get_model()
|
| 33 |
predictions = model.predict(text, k=1)
|
| 34 |
label = predictions[0][0].replace("__label__", "")
|
| 35 |
+
|
| 36 |
+
has_latin = bool(re.search(r"[a-zA-Z]", text))
|
| 37 |
+
has_devanagari = bool(re.search(r"[\u0900-\u097F]", text))
|
| 38 |
+
|
| 39 |
is_hinglish = has_latin and has_devanagari
|
| 40 |
+
|
| 41 |
if is_hinglish and label in ["en", "hi"]:
|
| 42 |
return "hinglish"
|
| 43 |
+
|
| 44 |
if label == "en":
|
| 45 |
return "en"
|
| 46 |
elif label == "hi":
|
src/data/preprocess.py
CHANGED
|
@@ -2,32 +2,33 @@ import re
|
|
| 2 |
import unicodedata
|
| 3 |
from src.data.transliterate import transliterate
|
| 4 |
|
|
|
|
| 5 |
def clean(text: str, language: str) -> str:
|
| 6 |
"""Clean text by lowercasing, removing URLs/mentions/hashtags, normalizing unicode, stripping whitespace."""
|
| 7 |
if not text:
|
| 8 |
return ""
|
| 9 |
-
|
| 10 |
# lowercase
|
| 11 |
text = text.lower()
|
| 12 |
-
|
| 13 |
# remove URLs
|
| 14 |
-
text = re.sub(r
|
| 15 |
-
|
| 16 |
# remove mentions
|
| 17 |
-
text = re.sub(r
|
| 18 |
-
|
| 19 |
# remove hashtags
|
| 20 |
-
text = re.sub(r
|
| 21 |
-
|
| 22 |
# Apply transliteration only for hi/hinglish inputs
|
| 23 |
if language in ["hi", "hinglish"]:
|
| 24 |
text = transliterate(text, language)
|
| 25 |
-
|
| 26 |
# normalize unicode
|
| 27 |
text = unicodedata.normalize("NFKC", text)
|
| 28 |
-
|
| 29 |
# strip whitespace
|
| 30 |
text = text.strip()
|
| 31 |
-
text = re.sub(r
|
| 32 |
-
|
| 33 |
return text
|
|
|
|
| 2 |
import unicodedata
|
| 3 |
from src.data.transliterate import transliterate
|
| 4 |
|
| 5 |
+
|
| 6 |
def clean(text: str, language: str) -> str:
|
| 7 |
"""Clean text by lowercasing, removing URLs/mentions/hashtags, normalizing unicode, stripping whitespace."""
|
| 8 |
if not text:
|
| 9 |
return ""
|
| 10 |
+
|
| 11 |
# lowercase
|
| 12 |
text = text.lower()
|
| 13 |
+
|
| 14 |
# remove URLs
|
| 15 |
+
text = re.sub(r"http\S+|www\.\S+", "", text)
|
| 16 |
+
|
| 17 |
# remove mentions
|
| 18 |
+
text = re.sub(r"@\w+", "", text)
|
| 19 |
+
|
| 20 |
# remove hashtags
|
| 21 |
+
text = re.sub(r"#\w+", "", text)
|
| 22 |
+
|
| 23 |
# Apply transliteration only for hi/hinglish inputs
|
| 24 |
if language in ["hi", "hinglish"]:
|
| 25 |
text = transliterate(text, language)
|
| 26 |
+
|
| 27 |
# normalize unicode
|
| 28 |
text = unicodedata.normalize("NFKC", text)
|
| 29 |
+
|
| 30 |
# strip whitespace
|
| 31 |
text = text.strip()
|
| 32 |
+
text = re.sub(r"\s+", " ", text)
|
| 33 |
+
|
| 34 |
return text
|
src/data/transliterate.py
CHANGED
|
@@ -1,22 +1,25 @@
|
|
| 1 |
import logging
|
| 2 |
import unicodedata
|
| 3 |
import re
|
| 4 |
-
from typing import Optional
|
| 5 |
|
| 6 |
logger = logging.getLogger(__name__)
|
| 7 |
|
| 8 |
try:
|
| 9 |
from indicnlp.transliterate.unicode_transliterate import ItransTransliterator
|
|
|
|
| 10 |
HAS_INDIC_NLP = True
|
| 11 |
except ImportError:
|
| 12 |
HAS_INDIC_NLP = False
|
| 13 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
def transliterate(text: str, src_lang: str) -> str:
|
| 16 |
"""Romanize Devanagari text."""
|
| 17 |
if src_lang not in ["hi", "hinglish"]:
|
| 18 |
return text
|
| 19 |
-
|
| 20 |
if HAS_INDIC_NLP:
|
| 21 |
try:
|
| 22 |
# We will process word by word if needed, but itrans translates string.
|
|
@@ -27,14 +30,18 @@ def transliterate(text: str, src_lang: str) -> str:
|
|
| 27 |
roman_text = text
|
| 28 |
else:
|
| 29 |
# Fallback to basic unicode normalization
|
| 30 |
-
roman_text =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
if not roman_text:
|
| 33 |
roman_text = text
|
| 34 |
-
|
| 35 |
# Normalize common Hinglish spellings
|
| 36 |
# replace acha / accha -> achha
|
| 37 |
-
roman_text = re.sub(r
|
| 38 |
-
roman_text = re.sub(r
|
| 39 |
-
|
| 40 |
return roman_text
|
|
|
|
| 1 |
import logging
|
| 2 |
import unicodedata
|
| 3 |
import re
|
|
|
|
| 4 |
|
| 5 |
logger = logging.getLogger(__name__)
|
| 6 |
|
| 7 |
try:
|
| 8 |
from indicnlp.transliterate.unicode_transliterate import ItransTransliterator
|
| 9 |
+
|
| 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:
|
| 19 |
"""Romanize Devanagari text."""
|
| 20 |
if src_lang not in ["hi", "hinglish"]:
|
| 21 |
return text
|
| 22 |
+
|
| 23 |
if HAS_INDIC_NLP:
|
| 24 |
try:
|
| 25 |
# We will process word by word if needed, but itrans translates string.
|
|
|
|
| 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
|
| 41 |
+
|
| 42 |
# Normalize common Hinglish spellings
|
| 43 |
# replace acha / accha -> achha
|
| 44 |
+
roman_text = re.sub(r"\baccha\b", "achha", roman_text, flags=re.IGNORECASE)
|
| 45 |
+
roman_text = re.sub(r"\bacha\b", "achha", roman_text, flags=re.IGNORECASE)
|
| 46 |
+
|
| 47 |
return roman_text
|
src/evaluation/benchmark_latency.py
CHANGED
|
@@ -1,22 +1,25 @@
|
|
| 1 |
"""
|
| 2 |
Script to benchmark latency for PyTorch, ONNX, and ONNX INT8 models on CPU.
|
| 3 |
"""
|
|
|
|
| 4 |
import time
|
| 5 |
-
import timeit
|
| 6 |
from pathlib import Path
|
| 7 |
import numpy as np
|
| 8 |
import torch
|
| 9 |
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
|
|
|
| 10 |
try:
|
| 11 |
from optimum.onnxruntime import ORTModelForSequenceClassification
|
|
|
|
| 12 |
OPTIMUM_AVAILABLE = True
|
| 13 |
except ImportError:
|
| 14 |
OPTIMUM_AVAILABLE = False
|
| 15 |
import mlflow
|
| 16 |
|
|
|
|
| 17 |
def benchmark_model(model, tokenizer, texts, model_type="pytorch"):
|
| 18 |
latencies = []
|
| 19 |
-
|
| 20 |
# Warmup
|
| 21 |
inputs = tokenizer(texts[:5], return_tensors="pt", padding=True, truncation=True)
|
| 22 |
if model_type == "pytorch":
|
|
@@ -24,11 +27,13 @@ def benchmark_model(model, tokenizer, texts, model_type="pytorch"):
|
|
| 24 |
model(**inputs)
|
| 25 |
else:
|
| 26 |
model(**inputs)
|
| 27 |
-
|
| 28 |
print(f"Benchmarking {model_type}...")
|
| 29 |
for text in texts:
|
| 30 |
-
inputs = tokenizer(
|
| 31 |
-
|
|
|
|
|
|
|
| 32 |
start_time = time.perf_counter()
|
| 33 |
if model_type == "pytorch":
|
| 34 |
with torch.no_grad():
|
|
@@ -36,67 +41,96 @@ def benchmark_model(model, tokenizer, texts, model_type="pytorch"):
|
|
| 36 |
else:
|
| 37 |
model(**inputs)
|
| 38 |
end_time = time.perf_counter()
|
| 39 |
-
|
| 40 |
-
latencies.append((end_time - start_time) * 1000)
|
| 41 |
-
|
| 42 |
mean_latency = np.mean(latencies)
|
| 43 |
p95_latency = np.percentile(latencies, 95)
|
| 44 |
-
throughput = len(texts) / (sum(latencies) / 1000)
|
| 45 |
-
|
| 46 |
return mean_latency, p95_latency, throughput
|
| 47 |
|
|
|
|
| 48 |
def main():
|
| 49 |
model_name = "xlm-roberta-base"
|
| 50 |
pytorch_dir = Path("models/sentiment/multilingual/best")
|
| 51 |
onnx_dir = Path("models/onnx/sentiment")
|
| 52 |
int8_dir = Path("models/onnx/sentiment_int8")
|
| 53 |
-
|
| 54 |
if not pytorch_dir.exists():
|
| 55 |
print(f"Directory {pytorch_dir} not found. Skipping benchmark.")
|
| 56 |
return
|
| 57 |
-
|
| 58 |
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 59 |
texts = ["This is a test sentence."] * 100
|
| 60 |
-
|
| 61 |
results = {}
|
| 62 |
-
|
| 63 |
# 1. PyTorch CPU
|
| 64 |
print("Loading PyTorch model...")
|
| 65 |
pt_model = AutoModelForSequenceClassification.from_pretrained(str(pytorch_dir))
|
| 66 |
pt_model.eval()
|
| 67 |
-
|
| 68 |
mean_pt, p95_pt, tput_pt = benchmark_model(pt_model, tokenizer, texts, "pytorch")
|
| 69 |
-
results["PyTorch (CPU)"] = {
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
if OPTIMUM_AVAILABLE:
|
| 72 |
# 2. ONNX CPU
|
| 73 |
if onnx_dir.exists():
|
| 74 |
print("Loading ONNX model...")
|
| 75 |
-
onnx_model = ORTModelForSequenceClassification.from_pretrained(
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
# 3. ONNX INT8 CPU
|
| 80 |
if int8_dir.exists():
|
| 81 |
print("Loading ONNX INT8 model...")
|
| 82 |
-
int8_model = ORTModelForSequenceClassification.from_pretrained(
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
print("\n--- Latency Benchmark Results ---")
|
| 87 |
-
print(
|
|
|
|
|
|
|
| 88 |
print("-" * 75)
|
| 89 |
for name, metrics in results.items():
|
| 90 |
-
print(
|
| 91 |
-
|
|
|
|
|
|
|
| 92 |
# Target check
|
| 93 |
if "ONNX INT8 (CPU)" in results:
|
| 94 |
int8_p95 = results["ONNX INT8 (CPU)"]["p95_ms"]
|
| 95 |
if int8_p95 < 300:
|
| 96 |
-
print(
|
|
|
|
|
|
|
| 97 |
else:
|
| 98 |
-
print(
|
| 99 |
-
|
|
|
|
|
|
|
| 100 |
mlflow.set_tracking_uri("sqlite:///mlflow.db")
|
| 101 |
mlflow.set_experiment("latency-benchmark")
|
| 102 |
with mlflow.start_run():
|
|
@@ -106,5 +140,6 @@ def main():
|
|
| 106 |
mlflow.log_metric(f"{prefix}_p95_latency", metrics["p95_ms"])
|
| 107 |
mlflow.log_metric(f"{prefix}_throughput", metrics["throughput"])
|
| 108 |
|
|
|
|
| 109 |
if __name__ == "__main__":
|
| 110 |
main()
|
|
|
|
| 1 |
"""
|
| 2 |
Script to benchmark latency for PyTorch, ONNX, and ONNX INT8 models on CPU.
|
| 3 |
"""
|
| 4 |
+
|
| 5 |
import time
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
import numpy as np
|
| 8 |
import torch
|
| 9 |
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 10 |
+
|
| 11 |
try:
|
| 12 |
from optimum.onnxruntime import ORTModelForSequenceClassification
|
| 13 |
+
|
| 14 |
OPTIMUM_AVAILABLE = True
|
| 15 |
except ImportError:
|
| 16 |
OPTIMUM_AVAILABLE = False
|
| 17 |
import mlflow
|
| 18 |
|
| 19 |
+
|
| 20 |
def benchmark_model(model, tokenizer, texts, model_type="pytorch"):
|
| 21 |
latencies = []
|
| 22 |
+
|
| 23 |
# Warmup
|
| 24 |
inputs = tokenizer(texts[:5], return_tensors="pt", padding=True, truncation=True)
|
| 25 |
if model_type == "pytorch":
|
|
|
|
| 27 |
model(**inputs)
|
| 28 |
else:
|
| 29 |
model(**inputs)
|
| 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":
|
| 39 |
with torch.no_grad():
|
|
|
|
| 41 |
else:
|
| 42 |
model(**inputs)
|
| 43 |
end_time = time.perf_counter()
|
| 44 |
+
|
| 45 |
+
latencies.append((end_time - start_time) * 1000) # ms
|
| 46 |
+
|
| 47 |
mean_latency = np.mean(latencies)
|
| 48 |
p95_latency = np.percentile(latencies, 95)
|
| 49 |
+
throughput = len(texts) / (sum(latencies) / 1000) # samples / sec
|
| 50 |
+
|
| 51 |
return mean_latency, p95_latency, throughput
|
| 52 |
|
| 53 |
+
|
| 54 |
def main():
|
| 55 |
model_name = "xlm-roberta-base"
|
| 56 |
pytorch_dir = Path("models/sentiment/multilingual/best")
|
| 57 |
onnx_dir = Path("models/onnx/sentiment")
|
| 58 |
int8_dir = Path("models/onnx/sentiment_int8")
|
| 59 |
+
|
| 60 |
if not pytorch_dir.exists():
|
| 61 |
print(f"Directory {pytorch_dir} not found. Skipping benchmark.")
|
| 62 |
return
|
| 63 |
+
|
| 64 |
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 65 |
texts = ["This is a test sentence."] * 100
|
| 66 |
+
|
| 67 |
results = {}
|
| 68 |
+
|
| 69 |
# 1. PyTorch CPU
|
| 70 |
print("Loading PyTorch model...")
|
| 71 |
pt_model = AutoModelForSequenceClassification.from_pretrained(str(pytorch_dir))
|
| 72 |
pt_model.eval()
|
| 73 |
+
|
| 74 |
mean_pt, p95_pt, tput_pt = benchmark_model(pt_model, tokenizer, texts, "pytorch")
|
| 75 |
+
results["PyTorch (CPU)"] = {
|
| 76 |
+
"mean_ms": mean_pt,
|
| 77 |
+
"p95_ms": p95_pt,
|
| 78 |
+
"throughput": tput_pt,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
if OPTIMUM_AVAILABLE:
|
| 82 |
# 2. ONNX CPU
|
| 83 |
if onnx_dir.exists():
|
| 84 |
print("Loading ONNX model...")
|
| 85 |
+
onnx_model = ORTModelForSequenceClassification.from_pretrained(
|
| 86 |
+
str(onnx_dir)
|
| 87 |
+
)
|
| 88 |
+
mean_onnx, p95_onnx, tput_onnx = benchmark_model(
|
| 89 |
+
onnx_model, tokenizer, texts, "onnx"
|
| 90 |
+
)
|
| 91 |
+
results["ONNX (CPU)"] = {
|
| 92 |
+
"mean_ms": mean_onnx,
|
| 93 |
+
"p95_ms": p95_onnx,
|
| 94 |
+
"throughput": tput_onnx,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
# 3. ONNX INT8 CPU
|
| 98 |
if int8_dir.exists():
|
| 99 |
print("Loading ONNX INT8 model...")
|
| 100 |
+
int8_model = ORTModelForSequenceClassification.from_pretrained(
|
| 101 |
+
str(int8_dir)
|
| 102 |
+
)
|
| 103 |
+
mean_int8, p95_int8, tput_int8 = benchmark_model(
|
| 104 |
+
int8_model, tokenizer, texts, "onnx_int8"
|
| 105 |
+
)
|
| 106 |
+
results["ONNX INT8 (CPU)"] = {
|
| 107 |
+
"mean_ms": mean_int8,
|
| 108 |
+
"p95_ms": p95_int8,
|
| 109 |
+
"throughput": tput_int8,
|
| 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")
|
| 136 |
with mlflow.start_run():
|
|
|
|
| 140 |
mlflow.log_metric(f"{prefix}_p95_latency", metrics["p95_ms"])
|
| 141 |
mlflow.log_metric(f"{prefix}_throughput", metrics["throughput"])
|
| 142 |
|
| 143 |
+
|
| 144 |
if __name__ == "__main__":
|
| 145 |
main()
|
src/evaluation/cross_lingual_eval.py
CHANGED
|
@@ -1,114 +1,134 @@
|
|
| 1 |
-
import os
|
| 2 |
import json
|
| 3 |
import torch
|
| 4 |
-
import numpy as np
|
| 5 |
from pathlib import Path
|
| 6 |
from transformers import (
|
| 7 |
AutoTokenizer,
|
| 8 |
AutoModelForTokenClassification,
|
| 9 |
AutoModelForSequenceClassification,
|
| 10 |
-
pipeline
|
| 11 |
)
|
| 12 |
from sklearn.metrics import f1_score
|
| 13 |
import mlflow
|
| 14 |
|
| 15 |
from src.training.mlflow_utils import setup_mlflow
|
| 16 |
-
|
| 17 |
|
| 18 |
def load_data(file_path: Path):
|
| 19 |
data = []
|
| 20 |
-
with open(file_path,
|
| 21 |
for line in f:
|
| 22 |
if line.strip():
|
| 23 |
data.append(json.loads(line))
|
| 24 |
return data
|
| 25 |
|
|
|
|
| 26 |
def main():
|
| 27 |
setup_mlflow()
|
| 28 |
-
|
| 29 |
# Check if models exist (might not if trained on Colab)
|
| 30 |
aspect_model_path = Path("models/aspect_extraction/best")
|
| 31 |
sentiment_model_path = Path("models/sentiment/best")
|
| 32 |
-
|
| 33 |
if not aspect_model_path.exists() or not sentiment_model_path.exists():
|
| 34 |
-
print(
|
|
|
|
|
|
|
| 35 |
return
|
| 36 |
-
|
| 37 |
print("Loading models...")
|
| 38 |
tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
|
| 39 |
-
|
| 40 |
-
aspect_model = AutoModelForTokenClassification.from_pretrained(
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
device = 0 if torch.cuda.is_available() else -1
|
| 44 |
-
|
| 45 |
-
ner_pipeline = pipeline(
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
# Load Hindi Data
|
| 48 |
hindi_path = Path("data/processed/amazon_hindi.jsonl")
|
| 49 |
hindi_data = load_data(hindi_path)
|
| 50 |
-
|
| 51 |
print(f"Evaluating zero-shot on {len(hindi_data)} Hindi samples...")
|
| 52 |
-
|
| 53 |
sentiment_map_rev = {0: "positive", 1: "negative", 2: "neutral", 3: "conflict"}
|
| 54 |
sentiment_map = {"positive": 0, "negative": 1, "neutral": 2, "conflict": 3}
|
| 55 |
-
|
| 56 |
true_labels = []
|
| 57 |
pred_labels = []
|
| 58 |
-
|
| 59 |
for item in hindi_data:
|
| 60 |
text = item["text"]
|
| 61 |
aspects = item.get("aspect_terms", [])
|
| 62 |
-
|
| 63 |
for aspect in aspects:
|
| 64 |
term = aspect["term"]
|
| 65 |
true_polarity = aspect["polarity"]
|
| 66 |
if true_polarity not in sentiment_map:
|
| 67 |
continue
|
| 68 |
-
|
| 69 |
true_labels.append(sentiment_map[true_polarity])
|
| 70 |
-
|
| 71 |
# Inference Sentiment
|
| 72 |
-
inputs = tokenizer(
|
|
|
|
|
|
|
| 73 |
if device == 0:
|
| 74 |
inputs = {k: v.to("cuda") for k, v in inputs.items()}
|
| 75 |
sentiment_model.to("cuda")
|
| 76 |
-
|
| 77 |
with torch.no_grad():
|
| 78 |
logits = sentiment_model(**inputs).logits
|
| 79 |
pred_idx = torch.argmax(logits, dim=1).item()
|
| 80 |
-
|
| 81 |
pred_labels.append(pred_idx)
|
| 82 |
-
|
| 83 |
-
hindi_macro_f1 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
print(f"Hindi Zero-Shot Macro-F1: {hindi_macro_f1}")
|
| 85 |
-
|
| 86 |
# We retrieve the best English test score from MLflow
|
| 87 |
# For now, let's just log the cross lingual gap if we know English F1
|
| 88 |
client = mlflow.tracking.MlflowClient()
|
| 89 |
experiment = client.get_experiment_by_name("multilingual-absa")
|
| 90 |
-
|
| 91 |
en_macro_f1 = 0.0
|
| 92 |
if experiment:
|
| 93 |
runs = client.search_runs(
|
| 94 |
experiment_ids=[experiment.experiment_id],
|
| 95 |
filter_string="metrics.test_macro_f1 > 0",
|
| 96 |
max_results=1,
|
| 97 |
-
order_by=["metrics.test_macro_f1 DESC"]
|
| 98 |
)
|
| 99 |
if runs:
|
| 100 |
en_macro_f1 = runs[0].data.metrics.get("test_macro_f1", 0.0)
|
| 101 |
-
|
| 102 |
print(f"Best English Test Macro-F1: {en_macro_f1}")
|
| 103 |
gap = en_macro_f1 - hindi_macro_f1
|
| 104 |
print(f"Cross-Lingual Gap: {gap}")
|
| 105 |
-
|
| 106 |
with mlflow.start_run(run_name="cross_lingual_eval"):
|
| 107 |
-
mlflow.log_metrics(
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
if __name__ == "__main__":
|
| 114 |
main()
|
|
|
|
|
|
|
| 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 src.training.mlflow_utils import setup_mlflow
|
| 14 |
+
|
| 15 |
|
| 16 |
def load_data(file_path: Path):
|
| 17 |
data = []
|
| 18 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 19 |
for line in f:
|
| 20 |
if line.strip():
|
| 21 |
data.append(json.loads(line))
|
| 22 |
return data
|
| 23 |
|
| 24 |
+
|
| 25 |
def main():
|
| 26 |
setup_mlflow()
|
| 27 |
+
|
| 28 |
# Check if models exist (might not if trained on Colab)
|
| 29 |
aspect_model_path = Path("models/aspect_extraction/best")
|
| 30 |
sentiment_model_path = Path("models/sentiment/best")
|
| 31 |
+
|
| 32 |
if not aspect_model_path.exists() or not sentiment_model_path.exists():
|
| 33 |
+
print(
|
| 34 |
+
"Models not found locally. Skipping cross-lingual evaluation until models are trained."
|
| 35 |
+
)
|
| 36 |
return
|
| 37 |
+
|
| 38 |
print("Loading models...")
|
| 39 |
tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
|
| 40 |
+
|
| 41 |
+
aspect_model = AutoModelForTokenClassification.from_pretrained(
|
| 42 |
+
str(aspect_model_path)
|
| 43 |
+
)
|
| 44 |
+
sentiment_model = AutoModelForSequenceClassification.from_pretrained(
|
| 45 |
+
str(sentiment_model_path)
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
device = 0 if torch.cuda.is_available() else -1
|
| 49 |
+
|
| 50 |
+
ner_pipeline = pipeline(
|
| 51 |
+
"token-classification",
|
| 52 |
+
model=aspect_model,
|
| 53 |
+
tokenizer=tokenizer,
|
| 54 |
+
device=device,
|
| 55 |
+
aggregation_strategy="simple",
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
# Load Hindi Data
|
| 59 |
hindi_path = Path("data/processed/amazon_hindi.jsonl")
|
| 60 |
hindi_data = load_data(hindi_path)
|
| 61 |
+
|
| 62 |
print(f"Evaluating zero-shot on {len(hindi_data)} Hindi samples...")
|
| 63 |
+
|
| 64 |
sentiment_map_rev = {0: "positive", 1: "negative", 2: "neutral", 3: "conflict"}
|
| 65 |
sentiment_map = {"positive": 0, "negative": 1, "neutral": 2, "conflict": 3}
|
| 66 |
+
|
| 67 |
true_labels = []
|
| 68 |
pred_labels = []
|
| 69 |
+
|
| 70 |
for item in hindi_data:
|
| 71 |
text = item["text"]
|
| 72 |
aspects = item.get("aspect_terms", [])
|
| 73 |
+
|
| 74 |
for aspect in aspects:
|
| 75 |
term = aspect["term"]
|
| 76 |
true_polarity = aspect["polarity"]
|
| 77 |
if true_polarity not in sentiment_map:
|
| 78 |
continue
|
| 79 |
+
|
| 80 |
true_labels.append(sentiment_map[true_polarity])
|
| 81 |
+
|
| 82 |
# Inference Sentiment
|
| 83 |
+
inputs = tokenizer(
|
| 84 |
+
text, term, return_tensors="pt", truncation=True, max_length=128
|
| 85 |
+
)
|
| 86 |
if device == 0:
|
| 87 |
inputs = {k: v.to("cuda") for k, v in inputs.items()}
|
| 88 |
sentiment_model.to("cuda")
|
| 89 |
+
|
| 90 |
with torch.no_grad():
|
| 91 |
logits = sentiment_model(**inputs).logits
|
| 92 |
pred_idx = torch.argmax(logits, dim=1).item()
|
| 93 |
+
|
| 94 |
pred_labels.append(pred_idx)
|
| 95 |
+
|
| 96 |
+
hindi_macro_f1 = (
|
| 97 |
+
f1_score(true_labels, pred_labels, average="macro")
|
| 98 |
+
if len(true_labels) > 0
|
| 99 |
+
else 0.0
|
| 100 |
+
)
|
| 101 |
print(f"Hindi Zero-Shot Macro-F1: {hindi_macro_f1}")
|
| 102 |
+
|
| 103 |
# We retrieve the best English test score from MLflow
|
| 104 |
# For now, let's just log the cross lingual gap if we know English F1
|
| 105 |
client = mlflow.tracking.MlflowClient()
|
| 106 |
experiment = client.get_experiment_by_name("multilingual-absa")
|
| 107 |
+
|
| 108 |
en_macro_f1 = 0.0
|
| 109 |
if experiment:
|
| 110 |
runs = client.search_runs(
|
| 111 |
experiment_ids=[experiment.experiment_id],
|
| 112 |
filter_string="metrics.test_macro_f1 > 0",
|
| 113 |
max_results=1,
|
| 114 |
+
order_by=["metrics.test_macro_f1 DESC"],
|
| 115 |
)
|
| 116 |
if runs:
|
| 117 |
en_macro_f1 = runs[0].data.metrics.get("test_macro_f1", 0.0)
|
| 118 |
+
|
| 119 |
print(f"Best English Test Macro-F1: {en_macro_f1}")
|
| 120 |
gap = en_macro_f1 - hindi_macro_f1
|
| 121 |
print(f"Cross-Lingual Gap: {gap}")
|
| 122 |
+
|
| 123 |
with mlflow.start_run(run_name="cross_lingual_eval"):
|
| 124 |
+
mlflow.log_metrics(
|
| 125 |
+
{
|
| 126 |
+
"hindi_zero_shot_macro_f1": float(hindi_macro_f1),
|
| 127 |
+
"english_test_macro_f1": float(en_macro_f1),
|
| 128 |
+
"cross_lingual_gap": float(gap),
|
| 129 |
+
}
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
|
| 133 |
if __name__ == "__main__":
|
| 134 |
main()
|
src/evaluation/final_eval.py
CHANGED
|
@@ -1,39 +1,61 @@
|
|
| 1 |
import os
|
| 2 |
import json
|
| 3 |
|
|
|
|
| 4 |
def run_evaluation():
|
| 5 |
# Mocking the evaluation process for Phase 8 as requested
|
| 6 |
-
|
| 7 |
metrics = [
|
| 8 |
-
{
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
{"Model": "ONNX FP32", "EN F1": "78.5%", "HI F1": "68.2%", "Latency": "520 ms"},
|
| 12 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
]
|
| 14 |
-
|
| 15 |
# Generate Markdown Table
|
| 16 |
md_table = "┌─────────────────────────┬──────────┬──────────┬───────────┐\n"
|
| 17 |
md_table += "│ Model │ EN F1 │ HI F1 │ Latency │\n"
|
| 18 |
md_table += "├─────────────────────────┼──────────┼──────────┼───────────┤\n"
|
| 19 |
-
|
| 20 |
for row in metrics:
|
| 21 |
md_table += f"│ {row['Model']:<23} │ {row['EN F1']:<8} │ {row['HI F1']:<8} │ {row['Latency']:<9} │\n"
|
| 22 |
-
|
| 23 |
md_table += "└─────────────────────────┴──────────┴──────────┴───────────┘\n"
|
| 24 |
-
|
| 25 |
print(md_table)
|
| 26 |
-
|
| 27 |
# Save as Markdown
|
| 28 |
os.makedirs("docs/results", exist_ok=True)
|
| 29 |
with open("docs/results/final_metrics.md", "w", encoding="utf-8") as f:
|
| 30 |
f.write(md_table)
|
| 31 |
-
|
| 32 |
# Save as JSON
|
| 33 |
with open("docs/results/final_metrics.json", "w", encoding="utf-8") as f:
|
| 34 |
json.dump(metrics, f, indent=4)
|
| 35 |
-
|
| 36 |
print("Final evaluation metrics saved to docs/results/final_metrics.md and .json")
|
| 37 |
|
|
|
|
| 38 |
if __name__ == "__main__":
|
| 39 |
run_evaluation()
|
|
|
|
| 1 |
import os
|
| 2 |
import json
|
| 3 |
|
| 4 |
+
|
| 5 |
def run_evaluation():
|
| 6 |
# Mocking the evaluation process for Phase 8 as requested
|
| 7 |
+
|
| 8 |
metrics = [
|
| 9 |
+
{
|
| 10 |
+
"Model": "Baseline TF-IDF+LR",
|
| 11 |
+
"EN F1": "62.4%",
|
| 12 |
+
"HI F1": "51.2%",
|
| 13 |
+
"Latency": "12 ms",
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"Model": "XLM-R (English only)",
|
| 17 |
+
"EN F1": "79.1%",
|
| 18 |
+
"HI F1": "42.5%",
|
| 19 |
+
"Latency": "850 ms",
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"Model": "XLM-R (Multilingual)",
|
| 23 |
+
"EN F1": "78.5%",
|
| 24 |
+
"HI F1": "68.2%",
|
| 25 |
+
"Latency": "870 ms",
|
| 26 |
+
},
|
| 27 |
{"Model": "ONNX FP32", "EN F1": "78.5%", "HI F1": "68.2%", "Latency": "520 ms"},
|
| 28 |
+
{
|
| 29 |
+
"Model": "ONNX INT8 (production)",
|
| 30 |
+
"EN F1": "78.1%",
|
| 31 |
+
"HI F1": "67.8%",
|
| 32 |
+
"Latency": "185 ms",
|
| 33 |
+
},
|
| 34 |
]
|
| 35 |
+
|
| 36 |
# Generate Markdown Table
|
| 37 |
md_table = "┌─────────────────────────┬──────────┬──────────┬───────────┐\n"
|
| 38 |
md_table += "│ Model │ EN F1 │ HI F1 │ Latency │\n"
|
| 39 |
md_table += "├─────────────────────────┼──────────┼──────────┼───────────┤\n"
|
| 40 |
+
|
| 41 |
for row in metrics:
|
| 42 |
md_table += f"│ {row['Model']:<23} │ {row['EN F1']:<8} │ {row['HI F1']:<8} │ {row['Latency']:<9} │\n"
|
| 43 |
+
|
| 44 |
md_table += "└─────────────────────────┴──────────┴──────────┴───────────┘\n"
|
| 45 |
+
|
| 46 |
print(md_table)
|
| 47 |
+
|
| 48 |
# Save as Markdown
|
| 49 |
os.makedirs("docs/results", exist_ok=True)
|
| 50 |
with open("docs/results/final_metrics.md", "w", encoding="utf-8") as f:
|
| 51 |
f.write(md_table)
|
| 52 |
+
|
| 53 |
# Save as JSON
|
| 54 |
with open("docs/results/final_metrics.json", "w", encoding="utf-8") as f:
|
| 55 |
json.dump(metrics, f, indent=4)
|
| 56 |
+
|
| 57 |
print("Final evaluation metrics saved to docs/results/final_metrics.md and .json")
|
| 58 |
|
| 59 |
+
|
| 60 |
if __name__ == "__main__":
|
| 61 |
run_evaluation()
|
src/models/baseline.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 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
|
|
@@ -9,15 +9,17 @@ from sklearn.metrics import f1_score, confusion_matrix, classification_report
|
|
| 9 |
import mlflow
|
| 10 |
from src.training.mlflow_utils import log_training_run
|
| 11 |
|
|
|
|
| 12 |
def load_data(file_paths: List[Path]) -> pd.DataFrame:
|
| 13 |
data = []
|
| 14 |
for path in file_paths:
|
| 15 |
-
with open(path,
|
| 16 |
for line in f:
|
| 17 |
if line.strip():
|
| 18 |
data.append(json.loads(line))
|
| 19 |
return pd.DataFrame(data)
|
| 20 |
|
|
|
|
| 21 |
def extract_sentence_sentiment(df: pd.DataFrame) -> pd.DataFrame:
|
| 22 |
"""
|
| 23 |
Extracts a sentence-level sentiment by taking the majority sentiment of aspects.
|
|
@@ -27,72 +29,81 @@ def extract_sentence_sentiment(df: pd.DataFrame) -> pd.DataFrame:
|
|
| 27 |
Wait, the requirement says "Sentence-level sentiment only (not ABSA)".
|
| 28 |
Let's just flatten it: pair each review text with the sentiment of its aspect,
|
| 29 |
but wait, a sentence might have multiple aspects with different sentiments.
|
| 30 |
-
If we do "Sentence-level sentiment only", we can just assign the sentence the label of the first aspect,
|
| 31 |
or we can construct a dataset of (text, sentiment) for every aspect but just predict sentiment from text alone.
|
| 32 |
Let's flatten it to (text, sentiment) pairs for every aspect to keep the dataset size comparable.
|
| 33 |
"""
|
| 34 |
records = []
|
| 35 |
sentiment_map = {"positive": 0, "negative": 1, "neutral": 2, "conflict": 3}
|
| 36 |
-
|
| 37 |
for _, row in df.iterrows():
|
| 38 |
-
text = row[
|
| 39 |
-
aspects = row.get(
|
| 40 |
-
|
| 41 |
for aspect in aspects:
|
| 42 |
-
polarity = aspect[
|
| 43 |
if polarity in sentiment_map:
|
| 44 |
-
records.append({
|
| 45 |
-
"text": text,
|
| 46 |
-
"label": sentiment_map[polarity]
|
| 47 |
-
})
|
| 48 |
return pd.DataFrame(records)
|
| 49 |
|
|
|
|
| 50 |
from sklearn.model_selection import train_test_split
|
| 51 |
|
|
|
|
| 52 |
def main():
|
| 53 |
data_dir = Path("data/processed")
|
| 54 |
train_path = data_dir / "semeval_train.jsonl"
|
| 55 |
-
|
| 56 |
# Load raw data
|
| 57 |
# Test path has no labels, so we only use train_path like we effectively did in hf_dataset
|
| 58 |
train_df_raw = load_data([train_path])
|
| 59 |
-
|
| 60 |
# Prepare flat sequence classification data
|
| 61 |
cls_df = extract_sentence_sentiment(train_df_raw)
|
| 62 |
-
|
| 63 |
# Exact same split logic as hf_dataset.py
|
| 64 |
-
train_cls, temp_cls = train_test_split(
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
print(f"Training on {len(X_train)} samples, testing on {len(X_test)} samples.")
|
| 74 |
-
|
| 75 |
# Baseline Model Pipeline
|
| 76 |
vectorizer = TfidfVectorizer(ngram_range=(1, 2), max_features=10000)
|
| 77 |
-
classifier = LogisticRegression(
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
# Train
|
| 80 |
print("Training TF-IDF + Logistic Regression...")
|
| 81 |
X_train_vec = vectorizer.fit_transform(X_train)
|
| 82 |
classifier.fit(X_train_vec, y_train)
|
| 83 |
-
|
| 84 |
# Evaluate
|
| 85 |
print("Evaluating...")
|
| 86 |
X_test_vec = vectorizer.transform(X_test)
|
| 87 |
y_pred = classifier.predict(X_test_vec)
|
| 88 |
-
|
| 89 |
# Metrics
|
| 90 |
macro_f1 = f1_score(y_test, y_pred, average="macro")
|
| 91 |
per_class_f1 = f1_score(y_test, y_pred, average=None)
|
| 92 |
conf_matrix = confusion_matrix(y_test, y_pred)
|
| 93 |
-
|
| 94 |
-
print(
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
# Format metrics for MLflow
|
| 97 |
metrics = {
|
| 98 |
"eval_macro_f1": float(macro_f1),
|
|
@@ -101,35 +112,35 @@ def main():
|
|
| 101 |
"eval_f1_neutral": float(per_class_f1[2]),
|
| 102 |
"eval_f1_conflict": float(per_class_f1[3] if len(per_class_f1) > 3 else 0.0),
|
| 103 |
}
|
| 104 |
-
|
| 105 |
# Also log confusion matrix as flattened or individual values (optional, can be artifact later)
|
| 106 |
# For now, print it. We will log it via mlflow log_dict or json artifact if we want, but let's just log metrics.
|
| 107 |
-
|
| 108 |
# Save Model
|
| 109 |
model_dir = Path("models/baseline")
|
| 110 |
model_dir.mkdir(parents=True, exist_ok=True)
|
| 111 |
model_path = model_dir / "tfidf_lr.pkl"
|
| 112 |
joblib.dump({"vectorizer": vectorizer, "classifier": classifier}, model_path)
|
| 113 |
print(f"Model saved to {model_path}")
|
| 114 |
-
|
| 115 |
# Log to MLflow
|
| 116 |
params = {
|
| 117 |
"model": "tfidf_lr",
|
| 118 |
"ngram_range": "(1, 2)",
|
| 119 |
"max_features": 10000,
|
| 120 |
"max_iter": 1000,
|
| 121 |
-
"class_weight": "balanced"
|
| 122 |
}
|
| 123 |
-
|
| 124 |
run_id = log_training_run(params, metrics, model_path, run_name="baseline_tfidf_lr")
|
| 125 |
-
|
| 126 |
# We can also explicitly log the confusion matrix as an artifact
|
| 127 |
-
import mlflow
|
| 128 |
with mlflow.start_run(run_id=run_id):
|
| 129 |
cm_dict = {"confusion_matrix": conf_matrix.tolist()}
|
| 130 |
mlflow.log_dict(cm_dict, "confusion_matrix.json")
|
| 131 |
-
|
| 132 |
print(f"MLflow run ID: {run_id}")
|
| 133 |
|
|
|
|
| 134 |
if __name__ == "__main__":
|
| 135 |
main()
|
|
|
|
| 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
|
|
|
|
| 9 |
import mlflow
|
| 10 |
from src.training.mlflow_utils import log_training_run
|
| 11 |
|
| 12 |
+
|
| 13 |
def load_data(file_paths: List[Path]) -> pd.DataFrame:
|
| 14 |
data = []
|
| 15 |
for path in file_paths:
|
| 16 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 17 |
for line in f:
|
| 18 |
if line.strip():
|
| 19 |
data.append(json.loads(line))
|
| 20 |
return pd.DataFrame(data)
|
| 21 |
|
| 22 |
+
|
| 23 |
def extract_sentence_sentiment(df: pd.DataFrame) -> pd.DataFrame:
|
| 24 |
"""
|
| 25 |
Extracts a sentence-level sentiment by taking the majority sentiment of aspects.
|
|
|
|
| 29 |
Wait, the requirement says "Sentence-level sentiment only (not ABSA)".
|
| 30 |
Let's just flatten it: pair each review text with the sentiment of its aspect,
|
| 31 |
but wait, a sentence might have multiple aspects with different sentiments.
|
| 32 |
+
If we do "Sentence-level sentiment only", we can just assign the sentence the label of the first aspect,
|
| 33 |
or we can construct a dataset of (text, sentiment) for every aspect but just predict sentiment from text alone.
|
| 34 |
Let's flatten it to (text, sentiment) pairs for every aspect to keep the dataset size comparable.
|
| 35 |
"""
|
| 36 |
records = []
|
| 37 |
sentiment_map = {"positive": 0, "negative": 1, "neutral": 2, "conflict": 3}
|
| 38 |
+
|
| 39 |
for _, row in df.iterrows():
|
| 40 |
+
text = row["text"]
|
| 41 |
+
aspects = row.get("aspect_terms", [])
|
| 42 |
+
|
| 43 |
for aspect in aspects:
|
| 44 |
+
polarity = aspect["polarity"]
|
| 45 |
if polarity in sentiment_map:
|
| 46 |
+
records.append({"text": text, "label": sentiment_map[polarity]})
|
|
|
|
|
|
|
|
|
|
| 47 |
return pd.DataFrame(records)
|
| 48 |
|
| 49 |
+
|
| 50 |
from sklearn.model_selection import train_test_split
|
| 51 |
|
| 52 |
+
|
| 53 |
def main():
|
| 54 |
data_dir = Path("data/processed")
|
| 55 |
train_path = data_dir / "semeval_train.jsonl"
|
| 56 |
+
|
| 57 |
# Load raw data
|
| 58 |
# Test path has no labels, so we only use train_path like we effectively did in hf_dataset
|
| 59 |
train_df_raw = load_data([train_path])
|
| 60 |
+
|
| 61 |
# Prepare flat sequence classification data
|
| 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(
|
| 66 |
+
cls_df, test_size=0.2, random_state=42, stratify=cls_df["label"]
|
| 67 |
+
)
|
| 68 |
+
val_cls, test_cls = train_test_split(
|
| 69 |
+
temp_cls, test_size=0.5, random_state=42, stratify=temp_cls["label"]
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
X_train = train_cls["text"].values
|
| 73 |
+
y_train = train_cls["label"].values
|
| 74 |
+
|
| 75 |
+
X_test = test_cls["text"].values
|
| 76 |
+
y_test = test_cls["label"].values
|
| 77 |
+
|
| 78 |
print(f"Training on {len(X_train)} samples, testing on {len(X_test)} samples.")
|
| 79 |
+
|
| 80 |
# Baseline Model Pipeline
|
| 81 |
vectorizer = TfidfVectorizer(ngram_range=(1, 2), max_features=10000)
|
| 82 |
+
classifier = LogisticRegression(
|
| 83 |
+
max_iter=1000, class_weight="balanced", random_state=42
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
# Train
|
| 87 |
print("Training TF-IDF + Logistic Regression...")
|
| 88 |
X_train_vec = vectorizer.fit_transform(X_train)
|
| 89 |
classifier.fit(X_train_vec, y_train)
|
| 90 |
+
|
| 91 |
# Evaluate
|
| 92 |
print("Evaluating...")
|
| 93 |
X_test_vec = vectorizer.transform(X_test)
|
| 94 |
y_pred = classifier.predict(X_test_vec)
|
| 95 |
+
|
| 96 |
# Metrics
|
| 97 |
macro_f1 = f1_score(y_test, y_pred, average="macro")
|
| 98 |
per_class_f1 = f1_score(y_test, y_pred, average=None)
|
| 99 |
conf_matrix = confusion_matrix(y_test, y_pred)
|
| 100 |
+
|
| 101 |
+
print(
|
| 102 |
+
classification_report(
|
| 103 |
+
y_test, y_pred, target_names=["positive", "negative", "neutral", "conflict"]
|
| 104 |
+
)
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
# Format metrics for MLflow
|
| 108 |
metrics = {
|
| 109 |
"eval_macro_f1": float(macro_f1),
|
|
|
|
| 112 |
"eval_f1_neutral": float(per_class_f1[2]),
|
| 113 |
"eval_f1_conflict": float(per_class_f1[3] if len(per_class_f1) > 3 else 0.0),
|
| 114 |
}
|
| 115 |
+
|
| 116 |
# Also log confusion matrix as flattened or individual values (optional, can be artifact later)
|
| 117 |
# For now, print it. We will log it via mlflow log_dict or json artifact if we want, but let's just log metrics.
|
| 118 |
+
|
| 119 |
# Save Model
|
| 120 |
model_dir = Path("models/baseline")
|
| 121 |
model_dir.mkdir(parents=True, exist_ok=True)
|
| 122 |
model_path = model_dir / "tfidf_lr.pkl"
|
| 123 |
joblib.dump({"vectorizer": vectorizer, "classifier": classifier}, model_path)
|
| 124 |
print(f"Model saved to {model_path}")
|
| 125 |
+
|
| 126 |
# Log to MLflow
|
| 127 |
params = {
|
| 128 |
"model": "tfidf_lr",
|
| 129 |
"ngram_range": "(1, 2)",
|
| 130 |
"max_features": 10000,
|
| 131 |
"max_iter": 1000,
|
| 132 |
+
"class_weight": "balanced",
|
| 133 |
}
|
| 134 |
+
|
| 135 |
run_id = log_training_run(params, metrics, model_path, run_name="baseline_tfidf_lr")
|
| 136 |
+
|
| 137 |
# We can also explicitly log the confusion matrix as an artifact
|
|
|
|
| 138 |
with mlflow.start_run(run_id=run_id):
|
| 139 |
cm_dict = {"confusion_matrix": conf_matrix.tolist()}
|
| 140 |
mlflow.log_dict(cm_dict, "confusion_matrix.json")
|
| 141 |
+
|
| 142 |
print(f"MLflow run ID: {run_id}")
|
| 143 |
|
| 144 |
+
|
| 145 |
if __name__ == "__main__":
|
| 146 |
main()
|
src/models/export_onnx.py
CHANGED
|
@@ -2,24 +2,28 @@
|
|
| 2 |
Script to export PyTorch models to ONNX format with INT8 quantization using Optimum.
|
| 3 |
Ensures dynamic axes for variable sequence length.
|
| 4 |
"""
|
| 5 |
-
|
| 6 |
from pathlib import Path
|
|
|
|
| 7 |
try:
|
| 8 |
-
from optimum.onnxruntime import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
from optimum.onnxruntime.configuration import AutoQuantizationConfig
|
|
|
|
| 10 |
OPTIMUM_AVAILABLE = True
|
| 11 |
except ImportError:
|
| 12 |
OPTIMUM_AVAILABLE = False
|
| 13 |
print("Warning: optimum library not installed. Models will not be exported.")
|
| 14 |
|
|
|
|
| 15 |
def export_and_quantize(
|
| 16 |
-
model_type: str,
|
| 17 |
-
source_dir: Path,
|
| 18 |
-
export_dir: Path,
|
| 19 |
-
quantize_dir: Path
|
| 20 |
):
|
| 21 |
print(f"Exporting {model_type} model from {source_dir} to {export_dir}")
|
| 22 |
-
|
| 23 |
if not source_dir.exists():
|
| 24 |
print(f"Source directory {source_dir} not found. Skipping export.")
|
| 25 |
# Create empty directories to satisfy deliverables
|
|
@@ -29,23 +33,28 @@ def export_and_quantize(
|
|
| 29 |
|
| 30 |
# Using dummy dynamic axes setup: Optimum handles this under the hood during export
|
| 31 |
# when `export=True` is passed for HF models, it sets dynamic sequence lengths automatically.
|
| 32 |
-
|
| 33 |
if model_type == "token_classification":
|
| 34 |
-
model = ORTModelForTokenClassification.from_pretrained(
|
|
|
|
|
|
|
| 35 |
elif model_type == "sequence_classification":
|
| 36 |
-
model = ORTModelForSequenceClassification.from_pretrained(
|
|
|
|
|
|
|
| 37 |
else:
|
| 38 |
raise ValueError(f"Unknown model_type: {model_type}")
|
| 39 |
-
|
| 40 |
model.save_pretrained(str(export_dir))
|
| 41 |
-
|
| 42 |
print(f"Quantizing to INT8 at {quantize_dir}")
|
| 43 |
quantizer = ORTQuantizer.from_pretrained(model)
|
| 44 |
qconfig = AutoQuantizationConfig.avx512_vnni(is_static=False, per_channel=False)
|
| 45 |
-
|
| 46 |
quantizer.quantize(save_dir=str(quantize_dir), quantization_config=qconfig)
|
| 47 |
print("Done quantization.")
|
| 48 |
|
|
|
|
| 49 |
def main():
|
| 50 |
if not OPTIMUM_AVAILABLE:
|
| 51 |
print("Please install optimum[onnxruntime] to run this script.")
|
|
@@ -55,22 +64,23 @@ def main():
|
|
| 55 |
Path("models/onnx/sentiment/").mkdir(parents=True, exist_ok=True)
|
| 56 |
Path("models/onnx/sentiment_int8/").mkdir(parents=True, exist_ok=True)
|
| 57 |
return
|
| 58 |
-
|
| 59 |
# 1. Aspect Extraction Model
|
| 60 |
export_and_quantize(
|
| 61 |
model_type="token_classification",
|
| 62 |
source_dir=Path("models/aspect_extraction/best"),
|
| 63 |
export_dir=Path("models/onnx/aspect_extraction"),
|
| 64 |
-
quantize_dir=Path("models/onnx/aspect_extraction_int8")
|
| 65 |
)
|
| 66 |
-
|
| 67 |
# 2. Sentiment Model (Multilingual)
|
| 68 |
export_and_quantize(
|
| 69 |
model_type="sequence_classification",
|
| 70 |
source_dir=Path("models/sentiment/multilingual/best"),
|
| 71 |
export_dir=Path("models/onnx/sentiment"),
|
| 72 |
-
quantize_dir=Path("models/onnx/sentiment_int8")
|
| 73 |
)
|
| 74 |
|
|
|
|
| 75 |
if __name__ == "__main__":
|
| 76 |
main()
|
|
|
|
| 2 |
Script to export PyTorch models to ONNX format with INT8 quantization using Optimum.
|
| 3 |
Ensures dynamic axes for variable sequence length.
|
| 4 |
"""
|
| 5 |
+
|
| 6 |
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
|
| 15 |
+
|
| 16 |
OPTIMUM_AVAILABLE = True
|
| 17 |
except ImportError:
|
| 18 |
OPTIMUM_AVAILABLE = False
|
| 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():
|
| 28 |
print(f"Source directory {source_dir} not found. Skipping export.")
|
| 29 |
# Create empty directories to satisfy deliverables
|
|
|
|
| 33 |
|
| 34 |
# Using dummy dynamic axes setup: Optimum handles this under the hood during export
|
| 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 |
+
|
| 48 |
model.save_pretrained(str(export_dir))
|
| 49 |
+
|
| 50 |
print(f"Quantizing to INT8 at {quantize_dir}")
|
| 51 |
quantizer = ORTQuantizer.from_pretrained(model)
|
| 52 |
qconfig = AutoQuantizationConfig.avx512_vnni(is_static=False, per_channel=False)
|
| 53 |
+
|
| 54 |
quantizer.quantize(save_dir=str(quantize_dir), quantization_config=qconfig)
|
| 55 |
print("Done quantization.")
|
| 56 |
|
| 57 |
+
|
| 58 |
def main():
|
| 59 |
if not OPTIMUM_AVAILABLE:
|
| 60 |
print("Please install optimum[onnxruntime] to run this script.")
|
|
|
|
| 64 |
Path("models/onnx/sentiment/").mkdir(parents=True, exist_ok=True)
|
| 65 |
Path("models/onnx/sentiment_int8/").mkdir(parents=True, exist_ok=True)
|
| 66 |
return
|
| 67 |
+
|
| 68 |
# 1. Aspect Extraction Model
|
| 69 |
export_and_quantize(
|
| 70 |
model_type="token_classification",
|
| 71 |
source_dir=Path("models/aspect_extraction/best"),
|
| 72 |
export_dir=Path("models/onnx/aspect_extraction"),
|
| 73 |
+
quantize_dir=Path("models/onnx/aspect_extraction_int8"),
|
| 74 |
)
|
| 75 |
+
|
| 76 |
# 2. Sentiment Model (Multilingual)
|
| 77 |
export_and_quantize(
|
| 78 |
model_type="sequence_classification",
|
| 79 |
source_dir=Path("models/sentiment/multilingual/best"),
|
| 80 |
export_dir=Path("models/onnx/sentiment"),
|
| 81 |
+
quantize_dir=Path("models/onnx/sentiment_int8"),
|
| 82 |
)
|
| 83 |
|
| 84 |
+
|
| 85 |
if __name__ == "__main__":
|
| 86 |
main()
|
src/models/train_aspect_extraction.py
CHANGED
|
@@ -1,5 +1,3 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import torch
|
| 3 |
import numpy as np
|
| 4 |
from pathlib import Path
|
| 5 |
from datasets import load_from_disk
|
|
@@ -9,28 +7,28 @@ from transformers import (
|
|
| 9 |
Trainer,
|
| 10 |
DataCollatorForTokenClassification,
|
| 11 |
AutoTokenizer,
|
| 12 |
-
set_seed
|
| 13 |
)
|
| 14 |
from seqeval.metrics import f1_score as seqeval_f1_score
|
| 15 |
-
from seqeval.metrics import classification_report
|
| 16 |
import mlflow
|
| 17 |
|
| 18 |
from src.training.mlflow_utils import setup_mlflow
|
| 19 |
|
|
|
|
| 20 |
def compute_metrics(p):
|
| 21 |
"""Computes evaluation metrics (F1 score) for token classification.
|
| 22 |
-
|
| 23 |
Args:
|
| 24 |
p: EvalPrediction tuple containing predictions and labels.
|
| 25 |
-
|
| 26 |
Returns:
|
| 27 |
Dictionary with 'f1' key and its computed value.
|
| 28 |
"""
|
| 29 |
predictions, labels = p
|
| 30 |
predictions = np.argmax(predictions, axis=2)
|
| 31 |
-
|
| 32 |
label_map = {0: "O", 1: "B-ASP", 2: "I-ASP"}
|
| 33 |
-
|
| 34 |
true_predictions = [
|
| 35 |
[label_map[p] for (p, l) in zip(prediction, label) if l != -100]
|
| 36 |
for prediction, label in zip(predictions, labels)
|
|
@@ -39,43 +37,42 @@ def compute_metrics(p):
|
|
| 39 |
[label_map[l] for (p, l) in zip(prediction, label) if l != -100]
|
| 40 |
for prediction, label in zip(predictions, labels)
|
| 41 |
]
|
| 42 |
-
|
| 43 |
# Seqeval F1 handles span-level scoring
|
| 44 |
f1 = seqeval_f1_score(true_labels, true_predictions)
|
| 45 |
-
|
| 46 |
# Calculate macro F1 roughly from classification report if needed,
|
| 47 |
# but for NER, seqeval's micro-averaged F1 (which seqeval_f1_score returns) is standard span-F1
|
| 48 |
-
return {
|
| 49 |
-
|
| 50 |
-
}
|
| 51 |
|
| 52 |
def main():
|
| 53 |
"""Main function to train and evaluate the aspect extraction model.
|
| 54 |
-
|
| 55 |
Loads tokenized dataset, initializes XLM-RoBERTa for token classification,
|
| 56 |
configures Trainer, executes training loop, evaluates on test set,
|
| 57 |
and logs results to MLflow.
|
| 58 |
"""
|
| 59 |
set_seed(42)
|
| 60 |
setup_mlflow()
|
| 61 |
-
|
| 62 |
dataset_path = Path("data/tokenized/absa_ner_dataset")
|
| 63 |
print(f"Loading dataset from {dataset_path}")
|
| 64 |
dataset = load_from_disk(str(dataset_path))
|
| 65 |
-
|
| 66 |
tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
|
| 67 |
data_collator = DataCollatorForTokenClassification(tokenizer=tokenizer)
|
| 68 |
-
|
| 69 |
label_map = {0: "O", 1: "B-ASP", 2: "I-ASP"}
|
| 70 |
model = AutoModelForTokenClassification.from_pretrained(
|
| 71 |
"xlm-roberta-base",
|
| 72 |
num_labels=len(label_map),
|
| 73 |
id2label=label_map,
|
| 74 |
-
label2id={v: k for k, v in label_map.items()}
|
| 75 |
)
|
| 76 |
-
|
| 77 |
output_dir = "models/aspect_extraction"
|
| 78 |
-
|
| 79 |
training_args = TrainingArguments(
|
| 80 |
output_dir=output_dir,
|
| 81 |
learning_rate=2e-5,
|
|
@@ -89,9 +86,9 @@ def main():
|
|
| 89 |
metric_for_best_model="eval_f1",
|
| 90 |
load_best_model_at_end=True,
|
| 91 |
seed=42,
|
| 92 |
-
report_to="mlflow"
|
| 93 |
)
|
| 94 |
-
|
| 95 |
trainer = Trainer(
|
| 96 |
model=model,
|
| 97 |
args=training_args,
|
|
@@ -99,28 +96,34 @@ def main():
|
|
| 99 |
eval_dataset=dataset["validation"],
|
| 100 |
tokenizer=tokenizer,
|
| 101 |
data_collator=data_collator,
|
| 102 |
-
compute_metrics=compute_metrics
|
| 103 |
)
|
| 104 |
-
|
| 105 |
print("Training Aspect Extraction model...")
|
| 106 |
trainer.train()
|
| 107 |
-
|
| 108 |
print("Evaluating on test set...")
|
| 109 |
test_results = trainer.evaluate(dataset["test"], metric_key_prefix="test")
|
| 110 |
print(test_results)
|
| 111 |
-
|
| 112 |
best_model_path = Path(output_dir) / "best"
|
| 113 |
trainer.save_model(str(best_model_path))
|
| 114 |
print(f"Best model saved to {best_model_path}")
|
| 115 |
-
|
| 116 |
-
# Log test metric manually since trainer.train() only automatically logs eval metrics
|
| 117 |
# if report_to="mlflow" handles it, but test results we need to make sure are in the same run.
|
| 118 |
-
with mlflow.start_run(
|
| 119 |
-
|
| 120 |
-
"
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
print(f"Logged test metrics to run {run.info.run_id}")
|
| 124 |
|
|
|
|
| 125 |
if __name__ == "__main__":
|
| 126 |
main()
|
|
|
|
|
|
|
|
|
|
| 1 |
import numpy as np
|
| 2 |
from pathlib import Path
|
| 3 |
from datasets import load_from_disk
|
|
|
|
| 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 src.training.mlflow_utils import setup_mlflow
|
| 16 |
|
| 17 |
+
|
| 18 |
def compute_metrics(p):
|
| 19 |
"""Computes evaluation metrics (F1 score) for token classification.
|
| 20 |
+
|
| 21 |
Args:
|
| 22 |
p: EvalPrediction tuple containing predictions and labels.
|
| 23 |
+
|
| 24 |
Returns:
|
| 25 |
Dictionary with 'f1' key and its computed value.
|
| 26 |
"""
|
| 27 |
predictions, labels = p
|
| 28 |
predictions = np.argmax(predictions, axis=2)
|
| 29 |
+
|
| 30 |
label_map = {0: "O", 1: "B-ASP", 2: "I-ASP"}
|
| 31 |
+
|
| 32 |
true_predictions = [
|
| 33 |
[label_map[p] for (p, l) in zip(prediction, label) if l != -100]
|
| 34 |
for prediction, label in zip(predictions, labels)
|
|
|
|
| 37 |
[label_map[l] for (p, l) in zip(prediction, label) if l != -100]
|
| 38 |
for prediction, label in zip(predictions, labels)
|
| 39 |
]
|
| 40 |
+
|
| 41 |
# Seqeval F1 handles span-level scoring
|
| 42 |
f1 = seqeval_f1_score(true_labels, true_predictions)
|
| 43 |
+
|
| 44 |
# Calculate macro F1 roughly from classification report if needed,
|
| 45 |
# but for NER, seqeval's micro-averaged F1 (which seqeval_f1_score returns) is standard span-F1
|
| 46 |
+
return {"f1": f1}
|
| 47 |
+
|
|
|
|
| 48 |
|
| 49 |
def main():
|
| 50 |
"""Main function to train and evaluate the aspect extraction model.
|
| 51 |
+
|
| 52 |
Loads tokenized dataset, initializes XLM-RoBERTa for token classification,
|
| 53 |
configures Trainer, executes training loop, evaluates on test set,
|
| 54 |
and logs results to MLflow.
|
| 55 |
"""
|
| 56 |
set_seed(42)
|
| 57 |
setup_mlflow()
|
| 58 |
+
|
| 59 |
dataset_path = Path("data/tokenized/absa_ner_dataset")
|
| 60 |
print(f"Loading dataset from {dataset_path}")
|
| 61 |
dataset = load_from_disk(str(dataset_path))
|
| 62 |
+
|
| 63 |
tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
|
| 64 |
data_collator = DataCollatorForTokenClassification(tokenizer=tokenizer)
|
| 65 |
+
|
| 66 |
label_map = {0: "O", 1: "B-ASP", 2: "I-ASP"}
|
| 67 |
model = AutoModelForTokenClassification.from_pretrained(
|
| 68 |
"xlm-roberta-base",
|
| 69 |
num_labels=len(label_map),
|
| 70 |
id2label=label_map,
|
| 71 |
+
label2id={v: k for k, v in label_map.items()},
|
| 72 |
)
|
| 73 |
+
|
| 74 |
output_dir = "models/aspect_extraction"
|
| 75 |
+
|
| 76 |
training_args = TrainingArguments(
|
| 77 |
output_dir=output_dir,
|
| 78 |
learning_rate=2e-5,
|
|
|
|
| 86 |
metric_for_best_model="eval_f1",
|
| 87 |
load_best_model_at_end=True,
|
| 88 |
seed=42,
|
| 89 |
+
report_to="mlflow",
|
| 90 |
)
|
| 91 |
+
|
| 92 |
trainer = Trainer(
|
| 93 |
model=model,
|
| 94 |
args=training_args,
|
|
|
|
| 96 |
eval_dataset=dataset["validation"],
|
| 97 |
tokenizer=tokenizer,
|
| 98 |
data_collator=data_collator,
|
| 99 |
+
compute_metrics=compute_metrics,
|
| 100 |
)
|
| 101 |
+
|
| 102 |
print("Training Aspect Extraction model...")
|
| 103 |
trainer.train()
|
| 104 |
+
|
| 105 |
print("Evaluating on test set...")
|
| 106 |
test_results = trainer.evaluate(dataset["test"], metric_key_prefix="test")
|
| 107 |
print(test_results)
|
| 108 |
+
|
| 109 |
best_model_path = Path(output_dir) / "best"
|
| 110 |
trainer.save_model(str(best_model_path))
|
| 111 |
print(f"Best model saved to {best_model_path}")
|
| 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 |
+
|
| 128 |
if __name__ == "__main__":
|
| 129 |
main()
|
src/models/train_joint_absa.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 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
|
|
@@ -11,41 +11,45 @@ from transformers import (
|
|
| 11 |
AutoTokenizer,
|
| 12 |
TrainingArguments,
|
| 13 |
Trainer,
|
| 14 |
-
set_seed
|
| 15 |
)
|
| 16 |
-
from datasets import load_dataset
|
| 17 |
import mlflow
|
| 18 |
import numpy as np
|
| 19 |
from sklearn.metrics import f1_score
|
| 20 |
-
from transformers.modeling_outputs import
|
|
|
|
|
|
|
|
|
|
| 21 |
from dataclasses import dataclass
|
| 22 |
from typing import Optional, Tuple
|
| 23 |
|
| 24 |
set_seed(42)
|
| 25 |
|
|
|
|
| 26 |
@dataclass
|
| 27 |
class JointModelOutput(TokenClassifierOutput, SequenceClassifierOutput):
|
| 28 |
loss: Optional[torch.FloatTensor] = None
|
| 29 |
-
ner_logits: torch.FloatTensor = None
|
| 30 |
-
cls_logits: torch.FloatTensor = None
|
| 31 |
hidden_states: Optional[Tuple[torch.FloatTensor]] = None
|
| 32 |
attentions: Optional[Tuple[torch.FloatTensor]] = None
|
| 33 |
|
|
|
|
| 34 |
class JointABSAModel(XLMRobertaPreTrainedModel):
|
| 35 |
def __init__(self, config, num_ner_labels=3, num_sentiment_labels=4):
|
| 36 |
super().__init__(config)
|
| 37 |
self.num_ner_labels = num_ner_labels
|
| 38 |
self.num_sentiment_labels = num_sentiment_labels
|
| 39 |
-
|
| 40 |
self.roberta = XLMRobertaModel(config, add_pooling_layer=False)
|
| 41 |
self.dropout = nn.Dropout(config.hidden_dropout_prob)
|
| 42 |
-
|
| 43 |
# Head 1: Token Classification (NER for Aspect Extraction)
|
| 44 |
self.ner_classifier = nn.Linear(config.hidden_size, num_ner_labels)
|
| 45 |
-
|
| 46 |
# Head 2: Sequence Classification (Sentiment)
|
| 47 |
self.sentiment_classifier = nn.Linear(config.hidden_size, num_sentiment_labels)
|
| 48 |
-
|
| 49 |
self.post_init()
|
| 50 |
|
| 51 |
def forward(
|
|
@@ -56,13 +60,15 @@ class JointABSAModel(XLMRobertaPreTrainedModel):
|
|
| 56 |
position_ids=None,
|
| 57 |
head_mask=None,
|
| 58 |
inputs_embeds=None,
|
| 59 |
-
labels=None,
|
| 60 |
-
sentiment_labels=None,
|
| 61 |
output_attentions=None,
|
| 62 |
output_hidden_states=None,
|
| 63 |
return_dict=None,
|
| 64 |
):
|
| 65 |
-
return_dict =
|
|
|
|
|
|
|
| 66 |
|
| 67 |
outputs = self.roberta(
|
| 68 |
input_ids,
|
|
@@ -78,29 +84,34 @@ class JointABSAModel(XLMRobertaPreTrainedModel):
|
|
| 78 |
|
| 79 |
sequence_output = outputs[0]
|
| 80 |
sequence_output = self.dropout(sequence_output)
|
| 81 |
-
|
| 82 |
# NER logits
|
| 83 |
ner_logits = self.ner_classifier(sequence_output)
|
| 84 |
-
|
| 85 |
# Sentiment logits (using CLS token)
|
| 86 |
cls_output = sequence_output[:, 0, :]
|
| 87 |
cls_logits = self.sentiment_classifier(cls_output)
|
| 88 |
-
|
| 89 |
loss = None
|
| 90 |
if labels is not None and sentiment_labels is not None:
|
| 91 |
loss_fct = nn.CrossEntropyLoss()
|
| 92 |
-
|
| 93 |
# NER Loss
|
| 94 |
active_loss = attention_mask.view(-1) == 1
|
| 95 |
active_logits = ner_logits.view(-1, self.num_ner_labels)
|
| 96 |
active_labels = torch.where(
|
| 97 |
-
active_loss,
|
|
|
|
|
|
|
| 98 |
)
|
| 99 |
ner_loss = loss_fct(active_logits, active_labels)
|
| 100 |
-
|
| 101 |
# Sentiment Loss
|
| 102 |
-
cls_loss = loss_fct(
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
| 104 |
# Combined Loss
|
| 105 |
loss = 0.5 * ner_loss + 0.5 * cls_loss
|
| 106 |
|
|
@@ -116,6 +127,7 @@ class JointABSAModel(XLMRobertaPreTrainedModel):
|
|
| 116 |
attentions=outputs.attentions,
|
| 117 |
)
|
| 118 |
|
|
|
|
| 119 |
class JointTrainer(Trainer):
|
| 120 |
def compute_loss(self, model, inputs, return_outputs=False):
|
| 121 |
labels = inputs.pop("labels")
|
|
@@ -124,40 +136,45 @@ class JointTrainer(Trainer):
|
|
| 124 |
loss = outputs.loss
|
| 125 |
return (loss, outputs) if return_outputs else loss
|
| 126 |
|
|
|
|
| 127 |
def compute_metrics(eval_pred) -> dict:
|
| 128 |
# eval_pred.predictions is a tuple: (ner_logits, cls_logits)
|
| 129 |
ner_logits, cls_logits = eval_pred.predictions
|
| 130 |
-
ner_labels = eval_pred.label_ids[
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
# Normally we would properly unpack the labels and calculate span F1 and macro F1
|
| 134 |
# For demonstration, computing random metrics based on dummy labels if not provided
|
| 135 |
# ... In a real setup, handle label pairing ...
|
| 136 |
-
|
| 137 |
cls_predictions = np.argmax(cls_logits, axis=-1)
|
| 138 |
# Placeholder for joint span f1 logic
|
| 139 |
-
joint_span_f1 = 0.75
|
| 140 |
-
|
| 141 |
# if sentiment_labels is available
|
| 142 |
if sentiment_labels is not None:
|
| 143 |
joint_macro_f1 = f1_score(sentiment_labels, cls_predictions, average="macro")
|
| 144 |
else:
|
| 145 |
joint_macro_f1 = 0.80
|
| 146 |
-
|
| 147 |
-
return {
|
| 148 |
-
|
| 149 |
-
"joint_macro_f1": joint_macro_f1
|
| 150 |
-
}
|
| 151 |
|
| 152 |
def main():
|
| 153 |
model_name = "xlm-roberta-base"
|
| 154 |
output_dir = Path("models/joint_absa/best")
|
| 155 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 156 |
-
|
| 157 |
print("Loading tokenizer and model...")
|
| 158 |
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 159 |
-
model = JointABSAModel.from_pretrained(
|
| 160 |
-
|
|
|
|
|
|
|
| 161 |
training_args = TrainingArguments(
|
| 162 |
output_dir=str(output_dir),
|
| 163 |
evaluation_strategy="epoch",
|
|
@@ -167,24 +184,27 @@ def main():
|
|
| 167 |
num_train_epochs=3,
|
| 168 |
weight_decay=0.01,
|
| 169 |
seed=42,
|
| 170 |
-
logging_dir=
|
| 171 |
logging_steps=10,
|
| 172 |
-
save_strategy="epoch"
|
| 173 |
)
|
| 174 |
-
|
| 175 |
# Placeholder dataset setup
|
| 176 |
# In practice, need a DataCollator that handles both `labels` and `sentiment_labels`
|
| 177 |
-
|
| 178 |
mlflow.set_tracking_uri("sqlite:///mlflow.db")
|
| 179 |
mlflow.set_experiment("joint-absa-training")
|
| 180 |
-
|
| 181 |
with mlflow.start_run():
|
| 182 |
# NOTE: Dummy dataset loading code omitted, this script sets up the model and loss structure
|
| 183 |
-
print(
|
| 184 |
-
|
|
|
|
|
|
|
| 185 |
# Log joint_span_f1 and joint_macro_f1 placeholder for API compatibility
|
| 186 |
mlflow.log_metric("joint_span_f1", 0.0)
|
| 187 |
mlflow.log_metric("joint_macro_f1", 0.0)
|
| 188 |
|
|
|
|
| 189 |
if __name__ == "__main__":
|
| 190 |
main()
|
|
|
|
| 1 |
"""
|
| 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
|
|
|
|
| 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 |
|
| 28 |
+
|
| 29 |
@dataclass
|
| 30 |
class JointModelOutput(TokenClassifierOutput, SequenceClassifierOutput):
|
| 31 |
loss: Optional[torch.FloatTensor] = None
|
| 32 |
+
ner_logits: Optional[torch.FloatTensor] = None
|
| 33 |
+
cls_logits: Optional[torch.FloatTensor] = None
|
| 34 |
hidden_states: Optional[Tuple[torch.FloatTensor]] = None
|
| 35 |
attentions: Optional[Tuple[torch.FloatTensor]] = None
|
| 36 |
|
| 37 |
+
|
| 38 |
class JointABSAModel(XLMRobertaPreTrainedModel):
|
| 39 |
def __init__(self, config, num_ner_labels=3, num_sentiment_labels=4):
|
| 40 |
super().__init__(config)
|
| 41 |
self.num_ner_labels = num_ner_labels
|
| 42 |
self.num_sentiment_labels = num_sentiment_labels
|
| 43 |
+
|
| 44 |
self.roberta = XLMRobertaModel(config, add_pooling_layer=False)
|
| 45 |
self.dropout = nn.Dropout(config.hidden_dropout_prob)
|
| 46 |
+
|
| 47 |
# Head 1: Token Classification (NER for Aspect Extraction)
|
| 48 |
self.ner_classifier = nn.Linear(config.hidden_size, num_ner_labels)
|
| 49 |
+
|
| 50 |
# Head 2: Sequence Classification (Sentiment)
|
| 51 |
self.sentiment_classifier = nn.Linear(config.hidden_size, num_sentiment_labels)
|
| 52 |
+
|
| 53 |
self.post_init()
|
| 54 |
|
| 55 |
def forward(
|
|
|
|
| 60 |
position_ids=None,
|
| 61 |
head_mask=None,
|
| 62 |
inputs_embeds=None,
|
| 63 |
+
labels=None, # NER labels
|
| 64 |
+
sentiment_labels=None, # Sentiment labels
|
| 65 |
output_attentions=None,
|
| 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,
|
|
|
|
| 84 |
|
| 85 |
sequence_output = outputs[0]
|
| 86 |
sequence_output = self.dropout(sequence_output)
|
| 87 |
+
|
| 88 |
# NER logits
|
| 89 |
ner_logits = self.ner_classifier(sequence_output)
|
| 90 |
+
|
| 91 |
# Sentiment logits (using CLS token)
|
| 92 |
cls_output = sequence_output[:, 0, :]
|
| 93 |
cls_logits = self.sentiment_classifier(cls_output)
|
| 94 |
+
|
| 95 |
loss = None
|
| 96 |
if labels is not None and sentiment_labels is not None:
|
| 97 |
loss_fct = nn.CrossEntropyLoss()
|
| 98 |
+
|
| 99 |
# NER Loss
|
| 100 |
active_loss = attention_mask.view(-1) == 1
|
| 101 |
active_logits = ner_logits.view(-1, self.num_ner_labels)
|
| 102 |
active_labels = torch.where(
|
| 103 |
+
active_loss,
|
| 104 |
+
labels.view(-1),
|
| 105 |
+
torch.tensor(loss_fct.ignore_index).type_as(labels),
|
| 106 |
)
|
| 107 |
ner_loss = loss_fct(active_logits, active_labels)
|
| 108 |
+
|
| 109 |
# Sentiment Loss
|
| 110 |
+
cls_loss = loss_fct(
|
| 111 |
+
cls_logits.view(-1, self.num_sentiment_labels),
|
| 112 |
+
sentiment_labels.view(-1),
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
# Combined Loss
|
| 116 |
loss = 0.5 * ner_loss + 0.5 * cls_loss
|
| 117 |
|
|
|
|
| 127 |
attentions=outputs.attentions,
|
| 128 |
)
|
| 129 |
|
| 130 |
+
|
| 131 |
class JointTrainer(Trainer):
|
| 132 |
def compute_loss(self, model, inputs, return_outputs=False):
|
| 133 |
labels = inputs.pop("labels")
|
|
|
|
| 136 |
loss = outputs.loss
|
| 137 |
return (loss, outputs) if return_outputs else loss
|
| 138 |
|
| 139 |
+
|
| 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 |
+
ner_labels = eval_pred.label_ids[
|
| 144 |
+
0
|
| 145 |
+
] # assuming we package them or trainer passes first
|
| 146 |
+
sentiment_labels = (
|
| 147 |
+
eval_pred.label_ids[1] if isinstance(eval_pred.label_ids, tuple) else None
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
# Normally we would properly unpack the labels and calculate span F1 and macro F1
|
| 151 |
# For demonstration, computing random metrics based on dummy labels if not provided
|
| 152 |
# ... In a real setup, handle label pairing ...
|
| 153 |
+
|
| 154 |
cls_predictions = np.argmax(cls_logits, axis=-1)
|
| 155 |
# Placeholder for joint span f1 logic
|
| 156 |
+
joint_span_f1 = 0.75
|
| 157 |
+
|
| 158 |
# if sentiment_labels is available
|
| 159 |
if sentiment_labels is not None:
|
| 160 |
joint_macro_f1 = f1_score(sentiment_labels, cls_predictions, average="macro")
|
| 161 |
else:
|
| 162 |
joint_macro_f1 = 0.80
|
| 163 |
+
|
| 164 |
+
return {"joint_span_f1": joint_span_f1, "joint_macro_f1": joint_macro_f1}
|
| 165 |
+
|
|
|
|
|
|
|
| 166 |
|
| 167 |
def main():
|
| 168 |
model_name = "xlm-roberta-base"
|
| 169 |
output_dir = Path("models/joint_absa/best")
|
| 170 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 171 |
+
|
| 172 |
print("Loading tokenizer and model...")
|
| 173 |
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 174 |
+
model = JointABSAModel.from_pretrained(
|
| 175 |
+
model_name, num_ner_labels=3, num_sentiment_labels=4
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
training_args = TrainingArguments(
|
| 179 |
output_dir=str(output_dir),
|
| 180 |
evaluation_strategy="epoch",
|
|
|
|
| 184 |
num_train_epochs=3,
|
| 185 |
weight_decay=0.01,
|
| 186 |
seed=42,
|
| 187 |
+
logging_dir="./logs",
|
| 188 |
logging_steps=10,
|
| 189 |
+
save_strategy="epoch",
|
| 190 |
)
|
| 191 |
+
|
| 192 |
# Placeholder dataset setup
|
| 193 |
# In practice, need a DataCollator that handles both `labels` and `sentiment_labels`
|
| 194 |
+
|
| 195 |
mlflow.set_tracking_uri("sqlite:///mlflow.db")
|
| 196 |
mlflow.set_experiment("joint-absa-training")
|
| 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)
|
| 206 |
mlflow.log_metric("joint_macro_f1", 0.0)
|
| 207 |
|
| 208 |
+
|
| 209 |
if __name__ == "__main__":
|
| 210 |
main()
|
src/models/train_multilingual.py
CHANGED
|
@@ -1,9 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
Script for multilingual fine-tuning of XLM-RoBERTa using language-aware sampling.
|
| 3 |
"""
|
| 4 |
-
|
| 5 |
from pathlib import Path
|
| 6 |
-
import torch
|
| 7 |
from torch.utils.data import WeightedRandomSampler
|
| 8 |
from transformers import (
|
| 9 |
AutoModelForSequenceClassification,
|
|
@@ -11,7 +10,7 @@ from transformers import (
|
|
| 11 |
TrainingArguments,
|
| 12 |
Trainer,
|
| 13 |
DataCollatorWithPadding,
|
| 14 |
-
set_seed
|
| 15 |
)
|
| 16 |
from datasets import load_dataset, concatenate_datasets
|
| 17 |
import mlflow
|
|
@@ -20,73 +19,83 @@ from sklearn.metrics import f1_score
|
|
| 20 |
|
| 21 |
set_seed(42)
|
| 22 |
|
|
|
|
| 23 |
def compute_metrics(eval_pred) -> dict:
|
| 24 |
predictions, labels = eval_pred
|
| 25 |
predictions = np.argmax(predictions, axis=1)
|
| 26 |
return {"macro_f1": f1_score(labels, predictions, average="macro")}
|
| 27 |
|
|
|
|
| 28 |
class LanguageAwareTrainer(Trainer):
|
| 29 |
def _get_train_sampler(self):
|
| 30 |
dataset = self.train_dataset
|
| 31 |
-
|
| 32 |
# Calculate weights to achieve 1:1 English:Hindi ratio
|
| 33 |
# Assuming dataset has a 'lang' feature
|
| 34 |
-
lang_labels = dataset[
|
| 35 |
-
en_count = sum(1 for l in lang_labels if l ==
|
| 36 |
-
hi_count = sum(1 for l in lang_labels if l ==
|
| 37 |
-
|
| 38 |
weights = []
|
| 39 |
for l in lang_labels:
|
| 40 |
-
if l ==
|
| 41 |
weights.append(1.0 / en_count if en_count > 0 else 0)
|
| 42 |
-
elif l ==
|
| 43 |
weights.append(1.0 / hi_count if hi_count > 0 else 0)
|
| 44 |
else:
|
| 45 |
weights.append(0)
|
| 46 |
-
|
| 47 |
# WeightedRandomSampler handles the sampling
|
| 48 |
-
return WeightedRandomSampler(
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
def main():
|
| 51 |
model_name = "xlm-roberta-base"
|
| 52 |
output_dir = Path("models/sentiment/multilingual/best")
|
| 53 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 54 |
-
|
| 55 |
data_dir = Path("data/processed")
|
| 56 |
en_train_file = data_dir / "semeval_train.jsonl"
|
| 57 |
hi_train_file = data_dir / "hindi_augmented.jsonl"
|
| 58 |
-
|
| 59 |
# NOTE: Dummy loading handling for execution without actual files
|
| 60 |
if not en_train_file.exists() or not hi_train_file.exists():
|
| 61 |
-
print(
|
|
|
|
|
|
|
| 62 |
return
|
| 63 |
|
| 64 |
print("Loading datasets...")
|
| 65 |
en_dataset = load_dataset("json", data_files={"train": str(en_train_file)})["train"]
|
| 66 |
hi_dataset = load_dataset("json", data_files={"train": str(hi_train_file)})["train"]
|
| 67 |
-
|
| 68 |
# Ensure they have a 'lang' column for our sampler
|
| 69 |
def add_en_lang(example):
|
| 70 |
-
example[
|
| 71 |
return example
|
|
|
|
| 72 |
def add_hi_lang(example):
|
| 73 |
-
example[
|
| 74 |
return example
|
| 75 |
-
|
| 76 |
en_dataset = en_dataset.map(add_en_lang)
|
| 77 |
hi_dataset = hi_dataset.map(add_hi_lang)
|
| 78 |
-
|
| 79 |
train_dataset = concatenate_datasets([en_dataset, hi_dataset])
|
| 80 |
-
|
| 81 |
print("Loading tokenizer and model...")
|
| 82 |
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 83 |
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=4)
|
| 84 |
-
|
| 85 |
def tokenize_function(examples):
|
| 86 |
-
return tokenizer(
|
| 87 |
-
|
|
|
|
|
|
|
| 88 |
tokenized_train = train_dataset.map(tokenize_function, batched=True)
|
| 89 |
-
|
| 90 |
training_args = TrainingArguments(
|
| 91 |
output_dir=str(output_dir),
|
| 92 |
evaluation_strategy="epoch",
|
|
@@ -96,39 +105,40 @@ def main():
|
|
| 96 |
num_train_epochs=3,
|
| 97 |
weight_decay=0.01,
|
| 98 |
seed=42,
|
| 99 |
-
save_strategy="epoch"
|
| 100 |
)
|
| 101 |
-
|
| 102 |
trainer = LanguageAwareTrainer(
|
| 103 |
model=model,
|
| 104 |
args=training_args,
|
| 105 |
train_dataset=tokenized_train,
|
| 106 |
tokenizer=tokenizer,
|
| 107 |
data_collator=DataCollatorWithPadding(tokenizer=tokenizer),
|
| 108 |
-
compute_metrics=compute_metrics
|
| 109 |
)
|
| 110 |
-
|
| 111 |
mlflow.set_tracking_uri("sqlite:///mlflow.db")
|
| 112 |
mlflow.set_experiment("multilingual-sentiment")
|
| 113 |
-
|
| 114 |
with mlflow.start_run():
|
| 115 |
print("Starting multilingual training...")
|
| 116 |
# trainer.train() # Uncomment to run actual training
|
| 117 |
-
|
| 118 |
# NOTE: Placeholder for evaluation logging
|
| 119 |
en_f1 = 0.82
|
| 120 |
hi_f1 = 0.68
|
| 121 |
combined_f1 = 0.75
|
| 122 |
gap = en_f1 - hi_f1
|
| 123 |
-
|
| 124 |
mlflow.log_metric("en_macro_f1", en_f1)
|
| 125 |
mlflow.log_metric("hi_macro_f1", hi_f1)
|
| 126 |
mlflow.log_metric("combined_macro_f1", combined_f1)
|
| 127 |
mlflow.log_metric("cross_lingual_gap", gap)
|
| 128 |
-
|
| 129 |
print("Saving model...")
|
| 130 |
model.save_pretrained(str(output_dir))
|
| 131 |
tokenizer.save_pretrained(str(output_dir))
|
| 132 |
|
|
|
|
| 133 |
if __name__ == "__main__":
|
| 134 |
main()
|
|
|
|
| 1 |
"""
|
| 2 |
Script for multilingual fine-tuning of XLM-RoBERTa using language-aware sampling.
|
| 3 |
"""
|
| 4 |
+
|
| 5 |
from pathlib import Path
|
|
|
|
| 6 |
from torch.utils.data import WeightedRandomSampler
|
| 7 |
from transformers import (
|
| 8 |
AutoModelForSequenceClassification,
|
|
|
|
| 10 |
TrainingArguments,
|
| 11 |
Trainer,
|
| 12 |
DataCollatorWithPadding,
|
| 13 |
+
set_seed,
|
| 14 |
)
|
| 15 |
from datasets import load_dataset, concatenate_datasets
|
| 16 |
import mlflow
|
|
|
|
| 19 |
|
| 20 |
set_seed(42)
|
| 21 |
|
| 22 |
+
|
| 23 |
def compute_metrics(eval_pred) -> dict:
|
| 24 |
predictions, labels = eval_pred
|
| 25 |
predictions = np.argmax(predictions, axis=1)
|
| 26 |
return {"macro_f1": f1_score(labels, predictions, average="macro")}
|
| 27 |
|
| 28 |
+
|
| 29 |
class LanguageAwareTrainer(Trainer):
|
| 30 |
def _get_train_sampler(self):
|
| 31 |
dataset = self.train_dataset
|
| 32 |
+
|
| 33 |
# Calculate weights to achieve 1:1 English:Hindi ratio
|
| 34 |
# Assuming dataset has a 'lang' feature
|
| 35 |
+
lang_labels = dataset["lang"]
|
| 36 |
+
en_count = sum(1 for l in lang_labels if l == "en")
|
| 37 |
+
hi_count = sum(1 for l in lang_labels if l == "hi")
|
| 38 |
+
|
| 39 |
weights = []
|
| 40 |
for l in lang_labels:
|
| 41 |
+
if l == "en":
|
| 42 |
weights.append(1.0 / en_count if en_count > 0 else 0)
|
| 43 |
+
elif l == "hi":
|
| 44 |
weights.append(1.0 / hi_count if hi_count > 0 else 0)
|
| 45 |
else:
|
| 46 |
weights.append(0)
|
| 47 |
+
|
| 48 |
# WeightedRandomSampler handles the sampling
|
| 49 |
+
return WeightedRandomSampler(
|
| 50 |
+
weights, num_samples=len(dataset), replacement=True
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
|
| 54 |
def main():
|
| 55 |
model_name = "xlm-roberta-base"
|
| 56 |
output_dir = Path("models/sentiment/multilingual/best")
|
| 57 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 58 |
+
|
| 59 |
data_dir = Path("data/processed")
|
| 60 |
en_train_file = data_dir / "semeval_train.jsonl"
|
| 61 |
hi_train_file = data_dir / "hindi_augmented.jsonl"
|
| 62 |
+
|
| 63 |
# NOTE: Dummy loading handling for execution without actual files
|
| 64 |
if not en_train_file.exists() or not hi_train_file.exists():
|
| 65 |
+
print(
|
| 66 |
+
"Missing dataset files. Ensure SemEval and Hindi augmented files are present."
|
| 67 |
+
)
|
| 68 |
return
|
| 69 |
|
| 70 |
print("Loading datasets...")
|
| 71 |
en_dataset = load_dataset("json", data_files={"train": str(en_train_file)})["train"]
|
| 72 |
hi_dataset = load_dataset("json", data_files={"train": str(hi_train_file)})["train"]
|
| 73 |
+
|
| 74 |
# Ensure they have a 'lang' column for our sampler
|
| 75 |
def add_en_lang(example):
|
| 76 |
+
example["lang"] = "en"
|
| 77 |
return example
|
| 78 |
+
|
| 79 |
def add_hi_lang(example):
|
| 80 |
+
example["lang"] = "hi"
|
| 81 |
return example
|
| 82 |
+
|
| 83 |
en_dataset = en_dataset.map(add_en_lang)
|
| 84 |
hi_dataset = hi_dataset.map(add_hi_lang)
|
| 85 |
+
|
| 86 |
train_dataset = concatenate_datasets([en_dataset, hi_dataset])
|
| 87 |
+
|
| 88 |
print("Loading tokenizer and model...")
|
| 89 |
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 90 |
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=4)
|
| 91 |
+
|
| 92 |
def tokenize_function(examples):
|
| 93 |
+
return tokenizer(
|
| 94 |
+
examples["text"], truncation=True, padding="max_length", max_length=128
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
tokenized_train = train_dataset.map(tokenize_function, batched=True)
|
| 98 |
+
|
| 99 |
training_args = TrainingArguments(
|
| 100 |
output_dir=str(output_dir),
|
| 101 |
evaluation_strategy="epoch",
|
|
|
|
| 105 |
num_train_epochs=3,
|
| 106 |
weight_decay=0.01,
|
| 107 |
seed=42,
|
| 108 |
+
save_strategy="epoch",
|
| 109 |
)
|
| 110 |
+
|
| 111 |
trainer = LanguageAwareTrainer(
|
| 112 |
model=model,
|
| 113 |
args=training_args,
|
| 114 |
train_dataset=tokenized_train,
|
| 115 |
tokenizer=tokenizer,
|
| 116 |
data_collator=DataCollatorWithPadding(tokenizer=tokenizer),
|
| 117 |
+
compute_metrics=compute_metrics,
|
| 118 |
)
|
| 119 |
+
|
| 120 |
mlflow.set_tracking_uri("sqlite:///mlflow.db")
|
| 121 |
mlflow.set_experiment("multilingual-sentiment")
|
| 122 |
+
|
| 123 |
with mlflow.start_run():
|
| 124 |
print("Starting multilingual training...")
|
| 125 |
# trainer.train() # Uncomment to run actual training
|
| 126 |
+
|
| 127 |
# NOTE: Placeholder for evaluation logging
|
| 128 |
en_f1 = 0.82
|
| 129 |
hi_f1 = 0.68
|
| 130 |
combined_f1 = 0.75
|
| 131 |
gap = en_f1 - hi_f1
|
| 132 |
+
|
| 133 |
mlflow.log_metric("en_macro_f1", en_f1)
|
| 134 |
mlflow.log_metric("hi_macro_f1", hi_f1)
|
| 135 |
mlflow.log_metric("combined_macro_f1", combined_f1)
|
| 136 |
mlflow.log_metric("cross_lingual_gap", gap)
|
| 137 |
+
|
| 138 |
print("Saving model...")
|
| 139 |
model.save_pretrained(str(output_dir))
|
| 140 |
tokenizer.save_pretrained(str(output_dir))
|
| 141 |
|
| 142 |
+
|
| 143 |
if __name__ == "__main__":
|
| 144 |
main()
|
src/models/train_qlora.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
Script for QLoRA fine-tuning of XLM-RoBERTa for sentiment analysis.
|
| 3 |
"""
|
| 4 |
-
|
| 5 |
from pathlib import Path
|
| 6 |
import torch
|
| 7 |
from transformers import (
|
|
@@ -11,7 +11,7 @@ from transformers import (
|
|
| 11 |
TrainingArguments,
|
| 12 |
Trainer,
|
| 13 |
DataCollatorWithPadding,
|
| 14 |
-
set_seed
|
| 15 |
)
|
| 16 |
from peft import get_peft_model, LoraConfig, TaskType
|
| 17 |
from datasets import load_dataset
|
|
@@ -22,38 +22,42 @@ from sklearn.metrics import f1_score
|
|
| 22 |
# Constraints: seed=42 everywhere
|
| 23 |
set_seed(42)
|
| 24 |
|
|
|
|
| 25 |
def compute_metrics(eval_pred) -> dict:
|
| 26 |
predictions, labels = eval_pred
|
| 27 |
predictions = np.argmax(predictions, axis=1)
|
| 28 |
macro_f1 = f1_score(labels, predictions, average="macro")
|
| 29 |
return {"macro_f1": macro_f1}
|
| 30 |
|
|
|
|
| 31 |
def main():
|
| 32 |
model_name = "xlm-roberta-base"
|
| 33 |
output_dir = Path("models/sentiment/qlora-adapter")
|
| 34 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 35 |
-
|
| 36 |
data_dir = Path("data/processed")
|
| 37 |
-
|
| 38 |
# 4-bit quantization config
|
| 39 |
try:
|
| 40 |
bnb_config = BitsAndBytesConfig(
|
| 41 |
load_in_4bit=True,
|
| 42 |
bnb_4bit_compute_dtype=torch.float16,
|
| 43 |
bnb_4bit_quant_type="nf4",
|
| 44 |
-
bnb_4bit_use_double_quant=True
|
| 45 |
)
|
| 46 |
except Exception as e:
|
| 47 |
-
print(
|
| 48 |
-
|
|
|
|
|
|
|
| 49 |
|
| 50 |
print("Loading tokenizer and model...")
|
| 51 |
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 52 |
model = AutoModelForSequenceClassification.from_pretrained(
|
| 53 |
model_name,
|
| 54 |
-
num_labels=4,
|
| 55 |
quantization_config=bnb_config if bnb_config else None,
|
| 56 |
-
device_map="auto"
|
| 57 |
)
|
| 58 |
|
| 59 |
lora_config = LoraConfig(
|
|
@@ -61,12 +65,12 @@ def main():
|
|
| 61 |
r=16,
|
| 62 |
lora_alpha=32,
|
| 63 |
lora_dropout=0.1,
|
| 64 |
-
target_modules=["query", "value"]
|
| 65 |
)
|
| 66 |
-
|
| 67 |
model = get_peft_model(model, lora_config)
|
| 68 |
model.print_trainable_parameters()
|
| 69 |
-
|
| 70 |
# NOTE: Assuming combined dataset is prepared or we combine them here.
|
| 71 |
# For now, we load a placeholder train dataset
|
| 72 |
train_file = data_dir / "semeval_train.jsonl"
|
|
@@ -75,12 +79,14 @@ def main():
|
|
| 75 |
return
|
| 76 |
|
| 77 |
dataset = load_dataset("json", data_files={"train": str(train_file)})
|
| 78 |
-
|
| 79 |
def tokenize_function(examples):
|
| 80 |
-
return tokenizer(
|
| 81 |
-
|
|
|
|
|
|
|
| 82 |
tokenized_datasets = dataset.map(tokenize_function, batched=True)
|
| 83 |
-
|
| 84 |
training_args = TrainingArguments(
|
| 85 |
output_dir=str(output_dir),
|
| 86 |
evaluation_strategy="epoch",
|
|
@@ -90,11 +96,11 @@ def main():
|
|
| 90 |
num_train_epochs=3,
|
| 91 |
weight_decay=0.01,
|
| 92 |
seed=42,
|
| 93 |
-
logging_dir=
|
| 94 |
logging_steps=10,
|
| 95 |
-
save_strategy="epoch"
|
| 96 |
)
|
| 97 |
-
|
| 98 |
trainer = Trainer(
|
| 99 |
model=model,
|
| 100 |
args=training_args,
|
|
@@ -102,21 +108,22 @@ def main():
|
|
| 102 |
# eval_dataset=tokenized_datasets["test"], # Add test set if available
|
| 103 |
tokenizer=tokenizer,
|
| 104 |
data_collator=DataCollatorWithPadding(tokenizer=tokenizer),
|
| 105 |
-
compute_metrics=compute_metrics
|
| 106 |
)
|
| 107 |
-
|
| 108 |
mlflow.set_tracking_uri("sqlite:///mlflow.db")
|
| 109 |
mlflow.set_experiment("qlora-sentiment")
|
| 110 |
-
|
| 111 |
with mlflow.start_run():
|
| 112 |
trainer.train()
|
| 113 |
-
|
| 114 |
# Save adapter
|
| 115 |
model.save_pretrained(str(output_dir))
|
| 116 |
tokenizer.save_pretrained(str(output_dir))
|
| 117 |
-
|
| 118 |
# Log adapter weights to MLflow
|
| 119 |
mlflow.log_artifacts(str(output_dir), artifact_path="qlora-adapter")
|
| 120 |
|
|
|
|
| 121 |
if __name__ == "__main__":
|
| 122 |
main()
|
|
|
|
| 1 |
"""
|
| 2 |
Script for QLoRA fine-tuning of XLM-RoBERTa for sentiment analysis.
|
| 3 |
"""
|
| 4 |
+
|
| 5 |
from pathlib import Path
|
| 6 |
import torch
|
| 7 |
from transformers import (
|
|
|
|
| 11 |
TrainingArguments,
|
| 12 |
Trainer,
|
| 13 |
DataCollatorWithPadding,
|
| 14 |
+
set_seed,
|
| 15 |
)
|
| 16 |
from peft import get_peft_model, LoraConfig, TaskType
|
| 17 |
from datasets import load_dataset
|
|
|
|
| 22 |
# Constraints: seed=42 everywhere
|
| 23 |
set_seed(42)
|
| 24 |
|
| 25 |
+
|
| 26 |
def compute_metrics(eval_pred) -> dict:
|
| 27 |
predictions, labels = eval_pred
|
| 28 |
predictions = np.argmax(predictions, axis=1)
|
| 29 |
macro_f1 = f1_score(labels, predictions, average="macro")
|
| 30 |
return {"macro_f1": macro_f1}
|
| 31 |
|
| 32 |
+
|
| 33 |
def main():
|
| 34 |
model_name = "xlm-roberta-base"
|
| 35 |
output_dir = Path("models/sentiment/qlora-adapter")
|
| 36 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
|
| 38 |
data_dir = Path("data/processed")
|
| 39 |
+
|
| 40 |
# 4-bit quantization config
|
| 41 |
try:
|
| 42 |
bnb_config = BitsAndBytesConfig(
|
| 43 |
load_in_4bit=True,
|
| 44 |
bnb_4bit_compute_dtype=torch.float16,
|
| 45 |
bnb_4bit_quant_type="nf4",
|
| 46 |
+
bnb_4bit_use_double_quant=True,
|
| 47 |
)
|
| 48 |
except Exception as e:
|
| 49 |
+
print(
|
| 50 |
+
f"Warning: bitsandbytes might not be supported on this system. Detailed error: {e}"
|
| 51 |
+
)
|
| 52 |
+
bnb_config = None # Fallback or error based on environment
|
| 53 |
|
| 54 |
print("Loading tokenizer and model...")
|
| 55 |
tokenizer = AutoTokenizer.from_pretrained(model_name)
|
| 56 |
model = AutoModelForSequenceClassification.from_pretrained(
|
| 57 |
model_name,
|
| 58 |
+
num_labels=4, # positive, negative, neutral, conflict
|
| 59 |
quantization_config=bnb_config if bnb_config else None,
|
| 60 |
+
device_map="auto",
|
| 61 |
)
|
| 62 |
|
| 63 |
lora_config = LoraConfig(
|
|
|
|
| 65 |
r=16,
|
| 66 |
lora_alpha=32,
|
| 67 |
lora_dropout=0.1,
|
| 68 |
+
target_modules=["query", "value"],
|
| 69 |
)
|
| 70 |
+
|
| 71 |
model = get_peft_model(model, lora_config)
|
| 72 |
model.print_trainable_parameters()
|
| 73 |
+
|
| 74 |
# NOTE: Assuming combined dataset is prepared or we combine them here.
|
| 75 |
# For now, we load a placeholder train dataset
|
| 76 |
train_file = data_dir / "semeval_train.jsonl"
|
|
|
|
| 79 |
return
|
| 80 |
|
| 81 |
dataset = load_dataset("json", data_files={"train": str(train_file)})
|
| 82 |
+
|
| 83 |
def tokenize_function(examples):
|
| 84 |
+
return tokenizer(
|
| 85 |
+
examples["text"], truncation=True, padding="max_length", max_length=128
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
tokenized_datasets = dataset.map(tokenize_function, batched=True)
|
| 89 |
+
|
| 90 |
training_args = TrainingArguments(
|
| 91 |
output_dir=str(output_dir),
|
| 92 |
evaluation_strategy="epoch",
|
|
|
|
| 96 |
num_train_epochs=3,
|
| 97 |
weight_decay=0.01,
|
| 98 |
seed=42,
|
| 99 |
+
logging_dir="./logs",
|
| 100 |
logging_steps=10,
|
| 101 |
+
save_strategy="epoch",
|
| 102 |
)
|
| 103 |
+
|
| 104 |
trainer = Trainer(
|
| 105 |
model=model,
|
| 106 |
args=training_args,
|
|
|
|
| 108 |
# eval_dataset=tokenized_datasets["test"], # Add test set if available
|
| 109 |
tokenizer=tokenizer,
|
| 110 |
data_collator=DataCollatorWithPadding(tokenizer=tokenizer),
|
| 111 |
+
compute_metrics=compute_metrics,
|
| 112 |
)
|
| 113 |
+
|
| 114 |
mlflow.set_tracking_uri("sqlite:///mlflow.db")
|
| 115 |
mlflow.set_experiment("qlora-sentiment")
|
| 116 |
+
|
| 117 |
with mlflow.start_run():
|
| 118 |
trainer.train()
|
| 119 |
+
|
| 120 |
# Save adapter
|
| 121 |
model.save_pretrained(str(output_dir))
|
| 122 |
tokenizer.save_pretrained(str(output_dir))
|
| 123 |
+
|
| 124 |
# Log adapter weights to MLflow
|
| 125 |
mlflow.log_artifacts(str(output_dir), artifact_path="qlora-adapter")
|
| 126 |
|
| 127 |
+
|
| 128 |
if __name__ == "__main__":
|
| 129 |
main()
|
src/models/train_sentiment.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
import os
|
| 2 |
import torch
|
| 3 |
import numpy as np
|
| 4 |
from pathlib import Path
|
|
@@ -9,28 +8,29 @@ from transformers import (
|
|
| 9 |
Trainer,
|
| 10 |
DataCollatorWithPadding,
|
| 11 |
AutoTokenizer,
|
| 12 |
-
set_seed
|
| 13 |
)
|
| 14 |
from sklearn.metrics import f1_score, confusion_matrix
|
| 15 |
import mlflow
|
| 16 |
|
| 17 |
from src.training.mlflow_utils import setup_mlflow
|
| 18 |
|
|
|
|
| 19 |
def compute_metrics(p):
|
| 20 |
"""Computes evaluation metrics (F1 score) for sequence classification.
|
| 21 |
-
|
| 22 |
Args:
|
| 23 |
p: EvalPrediction tuple containing predictions and labels.
|
| 24 |
-
|
| 25 |
Returns:
|
| 26 |
Dictionary with macro F1 and per-class F1 metrics.
|
| 27 |
"""
|
| 28 |
predictions, labels = p
|
| 29 |
predictions = np.argmax(predictions, axis=1)
|
| 30 |
-
|
| 31 |
macro_f1 = f1_score(labels, predictions, average="macro")
|
| 32 |
per_class_f1 = f1_score(labels, predictions, average=None)
|
| 33 |
-
|
| 34 |
# We will log confusion matrix in the main function
|
| 35 |
return {
|
| 36 |
"macro_f1": macro_f1,
|
|
@@ -40,52 +40,56 @@ def compute_metrics(p):
|
|
| 40 |
"f1_conflict": per_class_f1[3] if len(per_class_f1) > 3 else 0.0,
|
| 41 |
}
|
| 42 |
|
|
|
|
| 43 |
class ImbalancedTrainer(Trainer):
|
| 44 |
def __init__(self, class_weights=None, *args, **kwargs):
|
| 45 |
super().__init__(*args, **kwargs)
|
| 46 |
self.class_weights = class_weights
|
| 47 |
-
|
| 48 |
def compute_loss(self, model, inputs, return_outputs=False):
|
| 49 |
labels = inputs.pop("labels")
|
| 50 |
outputs = model(**inputs)
|
| 51 |
logits = outputs.logits
|
| 52 |
-
|
| 53 |
if self.class_weights is not None:
|
| 54 |
-
loss_fct = torch.nn.CrossEntropyLoss(
|
|
|
|
|
|
|
| 55 |
else:
|
| 56 |
loss_fct = torch.nn.CrossEntropyLoss()
|
| 57 |
-
|
| 58 |
loss = loss_fct(logits.view(-1, self.model.config.num_labels), labels.view(-1))
|
| 59 |
-
|
| 60 |
return (loss, outputs) if return_outputs else loss
|
| 61 |
|
|
|
|
| 62 |
def main():
|
| 63 |
"""Main function to train and evaluate the sentiment classification model.
|
| 64 |
-
|
| 65 |
Loads tokenized dataset, initializes XLM-RoBERTa for sequence classification,
|
| 66 |
handles class imbalances using a custom Trainer, executes training loop,
|
| 67 |
evaluates on test set, logs confusion matrix, and logs results to MLflow.
|
| 68 |
"""
|
| 69 |
set_seed(42)
|
| 70 |
setup_mlflow()
|
| 71 |
-
|
| 72 |
dataset_path = Path("data/tokenized/absa_cls_dataset")
|
| 73 |
print(f"Loading dataset from {dataset_path}")
|
| 74 |
dataset = load_from_disk(str(dataset_path))
|
| 75 |
-
|
| 76 |
tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
|
| 77 |
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
|
| 78 |
-
|
| 79 |
label_map = {0: "positive", 1: "negative", 2: "neutral", 3: "conflict"}
|
| 80 |
model = AutoModelForSequenceClassification.from_pretrained(
|
| 81 |
"xlm-roberta-base",
|
| 82 |
num_labels=len(label_map),
|
| 83 |
id2label=label_map,
|
| 84 |
-
label2id={v: k for k, v in label_map.items()}
|
| 85 |
)
|
| 86 |
-
|
| 87 |
output_dir = "models/sentiment"
|
| 88 |
-
|
| 89 |
training_args = TrainingArguments(
|
| 90 |
output_dir=output_dir,
|
| 91 |
learning_rate=2e-5,
|
|
@@ -99,15 +103,18 @@ def main():
|
|
| 99 |
metric_for_best_model="eval_macro_f1",
|
| 100 |
load_best_model_at_end=True,
|
| 101 |
seed=42,
|
| 102 |
-
report_to="mlflow"
|
| 103 |
)
|
| 104 |
-
|
| 105 |
# Calculate class weights for imbalanced dataset (especially 'conflict')
|
| 106 |
train_labels = dataset["train"]["label"]
|
| 107 |
from sklearn.utils.class_weight import compute_class_weight
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
| 109 |
class_weights_tensor = torch.tensor(class_weights, dtype=torch.float)
|
| 110 |
-
|
| 111 |
trainer = ImbalancedTrainer(
|
| 112 |
model=model,
|
| 113 |
args=training_args,
|
|
@@ -116,33 +123,42 @@ def main():
|
|
| 116 |
tokenizer=tokenizer,
|
| 117 |
data_collator=data_collator,
|
| 118 |
compute_metrics=compute_metrics,
|
| 119 |
-
class_weights=class_weights_tensor
|
| 120 |
)
|
| 121 |
-
|
| 122 |
print("Training Sentiment Classification model...")
|
| 123 |
trainer.train()
|
| 124 |
-
|
| 125 |
print("Evaluating on test set...")
|
| 126 |
test_results = trainer.evaluate(dataset["test"], metric_key_prefix="test")
|
| 127 |
print(test_results)
|
| 128 |
-
|
| 129 |
best_model_path = Path(output_dir) / "best"
|
| 130 |
trainer.save_model(str(best_model_path))
|
| 131 |
print(f"Best model saved to {best_model_path}")
|
| 132 |
-
|
| 133 |
# Confusion matrix on test set
|
| 134 |
predictions = trainer.predict(dataset["test"])
|
| 135 |
preds = np.argmax(predictions.predictions, axis=1)
|
| 136 |
labels = predictions.label_ids
|
| 137 |
cm = confusion_matrix(labels, preds)
|
| 138 |
-
|
| 139 |
-
with mlflow.start_run(
|
| 140 |
-
|
| 141 |
-
"
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
mlflow.log_dict({"confusion_matrix": cm.tolist()}, "confusion_matrix.json")
|
| 145 |
print(f"Logged test metrics and confusion matrix to run {run.info.run_id}")
|
| 146 |
|
|
|
|
| 147 |
if __name__ == "__main__":
|
| 148 |
main()
|
|
|
|
|
|
|
| 1 |
import torch
|
| 2 |
import numpy as np
|
| 3 |
from pathlib import Path
|
|
|
|
| 8 |
Trainer,
|
| 9 |
DataCollatorWithPadding,
|
| 10 |
AutoTokenizer,
|
| 11 |
+
set_seed,
|
| 12 |
)
|
| 13 |
from sklearn.metrics import f1_score, confusion_matrix
|
| 14 |
import mlflow
|
| 15 |
|
| 16 |
from src.training.mlflow_utils import setup_mlflow
|
| 17 |
|
| 18 |
+
|
| 19 |
def compute_metrics(p):
|
| 20 |
"""Computes evaluation metrics (F1 score) for sequence classification.
|
| 21 |
+
|
| 22 |
Args:
|
| 23 |
p: EvalPrediction tuple containing predictions and labels.
|
| 24 |
+
|
| 25 |
Returns:
|
| 26 |
Dictionary with macro F1 and per-class F1 metrics.
|
| 27 |
"""
|
| 28 |
predictions, labels = p
|
| 29 |
predictions = np.argmax(predictions, axis=1)
|
| 30 |
+
|
| 31 |
macro_f1 = f1_score(labels, predictions, average="macro")
|
| 32 |
per_class_f1 = f1_score(labels, predictions, average=None)
|
| 33 |
+
|
| 34 |
# We will log confusion matrix in the main function
|
| 35 |
return {
|
| 36 |
"macro_f1": macro_f1,
|
|
|
|
| 40 |
"f1_conflict": per_class_f1[3] if len(per_class_f1) > 3 else 0.0,
|
| 41 |
}
|
| 42 |
|
| 43 |
+
|
| 44 |
class ImbalancedTrainer(Trainer):
|
| 45 |
def __init__(self, class_weights=None, *args, **kwargs):
|
| 46 |
super().__init__(*args, **kwargs)
|
| 47 |
self.class_weights = class_weights
|
| 48 |
+
|
| 49 |
def compute_loss(self, model, inputs, return_outputs=False):
|
| 50 |
labels = inputs.pop("labels")
|
| 51 |
outputs = model(**inputs)
|
| 52 |
logits = outputs.logits
|
| 53 |
+
|
| 54 |
if self.class_weights is not None:
|
| 55 |
+
loss_fct = torch.nn.CrossEntropyLoss(
|
| 56 |
+
weight=self.class_weights.to(model.device)
|
| 57 |
+
)
|
| 58 |
else:
|
| 59 |
loss_fct = torch.nn.CrossEntropyLoss()
|
| 60 |
+
|
| 61 |
loss = loss_fct(logits.view(-1, self.model.config.num_labels), labels.view(-1))
|
| 62 |
+
|
| 63 |
return (loss, outputs) if return_outputs else loss
|
| 64 |
|
| 65 |
+
|
| 66 |
def main():
|
| 67 |
"""Main function to train and evaluate the sentiment classification model.
|
| 68 |
+
|
| 69 |
Loads tokenized dataset, initializes XLM-RoBERTa for sequence classification,
|
| 70 |
handles class imbalances using a custom Trainer, executes training loop,
|
| 71 |
evaluates on test set, logs confusion matrix, and logs results to MLflow.
|
| 72 |
"""
|
| 73 |
set_seed(42)
|
| 74 |
setup_mlflow()
|
| 75 |
+
|
| 76 |
dataset_path = Path("data/tokenized/absa_cls_dataset")
|
| 77 |
print(f"Loading dataset from {dataset_path}")
|
| 78 |
dataset = load_from_disk(str(dataset_path))
|
| 79 |
+
|
| 80 |
tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
|
| 81 |
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
|
| 82 |
+
|
| 83 |
label_map = {0: "positive", 1: "negative", 2: "neutral", 3: "conflict"}
|
| 84 |
model = AutoModelForSequenceClassification.from_pretrained(
|
| 85 |
"xlm-roberta-base",
|
| 86 |
num_labels=len(label_map),
|
| 87 |
id2label=label_map,
|
| 88 |
+
label2id={v: k for k, v in label_map.items()},
|
| 89 |
)
|
| 90 |
+
|
| 91 |
output_dir = "models/sentiment"
|
| 92 |
+
|
| 93 |
training_args = TrainingArguments(
|
| 94 |
output_dir=output_dir,
|
| 95 |
learning_rate=2e-5,
|
|
|
|
| 103 |
metric_for_best_model="eval_macro_f1",
|
| 104 |
load_best_model_at_end=True,
|
| 105 |
seed=42,
|
| 106 |
+
report_to="mlflow",
|
| 107 |
)
|
| 108 |
+
|
| 109 |
# Calculate class weights for imbalanced dataset (especially 'conflict')
|
| 110 |
train_labels = dataset["train"]["label"]
|
| 111 |
from sklearn.utils.class_weight import compute_class_weight
|
| 112 |
+
|
| 113 |
+
class_weights = compute_class_weight(
|
| 114 |
+
"balanced", classes=np.unique(train_labels), y=train_labels
|
| 115 |
+
)
|
| 116 |
class_weights_tensor = torch.tensor(class_weights, dtype=torch.float)
|
| 117 |
+
|
| 118 |
trainer = ImbalancedTrainer(
|
| 119 |
model=model,
|
| 120 |
args=training_args,
|
|
|
|
| 123 |
tokenizer=tokenizer,
|
| 124 |
data_collator=data_collator,
|
| 125 |
compute_metrics=compute_metrics,
|
| 126 |
+
class_weights=class_weights_tensor,
|
| 127 |
)
|
| 128 |
+
|
| 129 |
print("Training Sentiment Classification model...")
|
| 130 |
trainer.train()
|
| 131 |
+
|
| 132 |
print("Evaluating on test set...")
|
| 133 |
test_results = trainer.evaluate(dataset["test"], metric_key_prefix="test")
|
| 134 |
print(test_results)
|
| 135 |
+
|
| 136 |
best_model_path = Path(output_dir) / "best"
|
| 137 |
trainer.save_model(str(best_model_path))
|
| 138 |
print(f"Best model saved to {best_model_path}")
|
| 139 |
+
|
| 140 |
# Confusion matrix on test set
|
| 141 |
predictions = trainer.predict(dataset["test"])
|
| 142 |
preds = np.argmax(predictions.predictions, axis=1)
|
| 143 |
labels = predictions.label_ids
|
| 144 |
cm = confusion_matrix(labels, preds)
|
| 145 |
+
|
| 146 |
+
with mlflow.start_run(
|
| 147 |
+
run_id=(
|
| 148 |
+
trainer.state.trial_params.get("mlflow_run_id")
|
| 149 |
+
if trainer.state.trial_params
|
| 150 |
+
else mlflow.active_run().info.run_id if mlflow.active_run() else None
|
| 151 |
+
)
|
| 152 |
+
) as run:
|
| 153 |
+
mlflow.log_metrics(
|
| 154 |
+
{
|
| 155 |
+
"test_macro_f1": test_results["test_macro_f1"],
|
| 156 |
+
"test_loss": test_results["test_loss"],
|
| 157 |
+
}
|
| 158 |
+
)
|
| 159 |
mlflow.log_dict({"confusion_matrix": cm.tolist()}, "confusion_matrix.json")
|
| 160 |
print(f"Logged test metrics and confusion matrix to run {run.info.run_id}")
|
| 161 |
|
| 162 |
+
|
| 163 |
if __name__ == "__main__":
|
| 164 |
main()
|
src/training/mlflow_utils.py
CHANGED
|
@@ -1,73 +1,84 @@
|
|
| 1 |
import mlflow
|
| 2 |
-
from typing import Dict, Any, Optional
|
| 3 |
from pathlib import Path
|
| 4 |
-
import os
|
| 5 |
|
| 6 |
# Default configuration
|
| 7 |
MLFLOW_TRACKING_URI = "sqlite:///mlflow/mlflow.db"
|
| 8 |
EXPERIMENT_NAME = "multilingual-absa"
|
| 9 |
|
|
|
|
| 10 |
def setup_mlflow():
|
| 11 |
"""Initializes MLflow tracking URI and experiment."""
|
| 12 |
# Ensure the directory exists
|
| 13 |
Path("mlflow").mkdir(parents=True, exist_ok=True)
|
| 14 |
-
|
| 15 |
mlflow.set_tracking_uri(MLFLOW_TRACKING_URI)
|
| 16 |
mlflow.set_experiment(EXPERIMENT_NAME)
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
"""
|
| 20 |
Logs parameters, metrics, and optionally a model artifact to MLflow.
|
| 21 |
-
|
| 22 |
Args:
|
| 23 |
params: Dictionary of hyperparameters or configuration.
|
| 24 |
metrics: Dictionary of evaluation metrics.
|
| 25 |
model_path: Path to the saved model directory or file.
|
| 26 |
run_name: Optional name for the run.
|
| 27 |
-
|
| 28 |
Returns:
|
| 29 |
The ID of the created MLflow run.
|
| 30 |
"""
|
| 31 |
setup_mlflow()
|
| 32 |
-
|
| 33 |
with mlflow.start_run(run_name=run_name) as run:
|
| 34 |
mlflow.log_params(params)
|
| 35 |
mlflow.log_metrics(metrics)
|
| 36 |
-
|
| 37 |
if model_path:
|
| 38 |
model_path_obj = Path(model_path)
|
| 39 |
if model_path_obj.exists():
|
| 40 |
mlflow.log_artifact(str(model_path_obj), artifact_path="model")
|
| 41 |
else:
|
| 42 |
-
print(
|
| 43 |
-
|
|
|
|
|
|
|
| 44 |
return run.info.run_id
|
| 45 |
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
| 47 |
"""
|
| 48 |
Retrieves the best run from the experiment based on a specific metric.
|
| 49 |
-
|
| 50 |
Args:
|
| 51 |
metric: The metric to sort by.
|
| 52 |
ascending: True if a lower metric is better (e.g., loss), False for higher is better (e.g., F1).
|
| 53 |
-
|
| 54 |
Returns:
|
| 55 |
The MLflow Run object for the best run, or None if no runs exist.
|
| 56 |
"""
|
| 57 |
setup_mlflow()
|
| 58 |
-
|
| 59 |
experiment = mlflow.get_experiment_by_name(EXPERIMENT_NAME)
|
| 60 |
if not experiment:
|
| 61 |
return None
|
| 62 |
-
|
| 63 |
runs = mlflow.search_runs(
|
| 64 |
experiment_ids=[experiment.experiment_id],
|
| 65 |
order_by=[f"metrics.{metric} {'ASC' if ascending else 'DESC'}"],
|
| 66 |
max_results=1,
|
| 67 |
-
output_format="list"
|
| 68 |
)
|
| 69 |
-
|
| 70 |
if not runs:
|
| 71 |
return None
|
| 72 |
-
|
| 73 |
return runs[0]
|
|
|
|
| 1 |
import mlflow
|
| 2 |
+
from typing import Dict, Any, Optional, Union
|
| 3 |
from pathlib import Path
|
|
|
|
| 4 |
|
| 5 |
# Default configuration
|
| 6 |
MLFLOW_TRACKING_URI = "sqlite:///mlflow/mlflow.db"
|
| 7 |
EXPERIMENT_NAME = "multilingual-absa"
|
| 8 |
|
| 9 |
+
|
| 10 |
def setup_mlflow():
|
| 11 |
"""Initializes MLflow tracking URI and experiment."""
|
| 12 |
# Ensure the directory exists
|
| 13 |
Path("mlflow").mkdir(parents=True, exist_ok=True)
|
| 14 |
+
|
| 15 |
mlflow.set_tracking_uri(MLFLOW_TRACKING_URI)
|
| 16 |
mlflow.set_experiment(EXPERIMENT_NAME)
|
| 17 |
|
| 18 |
+
|
| 19 |
+
def log_training_run(
|
| 20 |
+
params: Dict[str, Any],
|
| 21 |
+
metrics: Dict[str, float],
|
| 22 |
+
model_path: Optional[Union[str, Path]] = None,
|
| 23 |
+
run_name: Optional[str] = None,
|
| 24 |
+
) -> str:
|
| 25 |
"""
|
| 26 |
Logs parameters, metrics, and optionally a model artifact to MLflow.
|
| 27 |
+
|
| 28 |
Args:
|
| 29 |
params: Dictionary of hyperparameters or configuration.
|
| 30 |
metrics: Dictionary of evaluation metrics.
|
| 31 |
model_path: Path to the saved model directory or file.
|
| 32 |
run_name: Optional name for the run.
|
| 33 |
+
|
| 34 |
Returns:
|
| 35 |
The ID of the created MLflow run.
|
| 36 |
"""
|
| 37 |
setup_mlflow()
|
| 38 |
+
|
| 39 |
with mlflow.start_run(run_name=run_name) as run:
|
| 40 |
mlflow.log_params(params)
|
| 41 |
mlflow.log_metrics(metrics)
|
| 42 |
+
|
| 43 |
if model_path:
|
| 44 |
model_path_obj = Path(model_path)
|
| 45 |
if model_path_obj.exists():
|
| 46 |
mlflow.log_artifact(str(model_path_obj), artifact_path="model")
|
| 47 |
else:
|
| 48 |
+
print(
|
| 49 |
+
f"Warning: Model path {model_path} does not exist. Artifact not logged."
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
return run.info.run_id
|
| 53 |
|
| 54 |
+
|
| 55 |
+
def get_best_run(
|
| 56 |
+
metric: str = "eval_macro_f1", ascending: bool = False
|
| 57 |
+
) -> Optional[Any]: # type: ignore
|
| 58 |
"""
|
| 59 |
Retrieves the best run from the experiment based on a specific metric.
|
| 60 |
+
|
| 61 |
Args:
|
| 62 |
metric: The metric to sort by.
|
| 63 |
ascending: True if a lower metric is better (e.g., loss), False for higher is better (e.g., F1).
|
| 64 |
+
|
| 65 |
Returns:
|
| 66 |
The MLflow Run object for the best run, or None if no runs exist.
|
| 67 |
"""
|
| 68 |
setup_mlflow()
|
| 69 |
+
|
| 70 |
experiment = mlflow.get_experiment_by_name(EXPERIMENT_NAME)
|
| 71 |
if not experiment:
|
| 72 |
return None
|
| 73 |
+
|
| 74 |
runs = mlflow.search_runs(
|
| 75 |
experiment_ids=[experiment.experiment_id],
|
| 76 |
order_by=[f"metrics.{metric} {'ASC' if ascending else 'DESC'}"],
|
| 77 |
max_results=1,
|
| 78 |
+
output_format="list",
|
| 79 |
)
|
| 80 |
+
|
| 81 |
if not runs:
|
| 82 |
return None
|
| 83 |
+
|
| 84 |
return runs[0]
|