Aryan Mishra commited on
Commit
6c2294e
·
1 Parent(s): 927450f

Refactor API and training scripts

Browse files

Cleans 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 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(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
 
 
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(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
 
 
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, BackgroundTasks
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('.csv'):
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(status_code=422, detail="CSV must contain a 'text' column.")
67
-
 
 
68
  if len(df) > 10000:
69
  os.unlink(tmp_path)
70
- raise HTTPException(status_code=422, detail="Max 10,000 rows allowed per batch.")
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(status_code=500, detail=f"Batch processing failed: {str(e)}")
 
 
 
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
- "status": "ok",
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
- # Audio
43
- "sound quality", "audio quality", "bass response", "bass", "treble",
44
- "noise cancellation", "active noise cancellation", "anc", "passive noise isolation",
45
- "microphone quality", "microphone", "mic", "speakers", "speaker",
46
- "audio", "sound", "volume",
47
- # Battery / Power
48
- "battery life", "battery performance", "charging speed", "fast charging",
49
- "wireless charging", "charging case", "battery", "charging", "power",
50
- # Design / Build
51
- "build quality", "build", "design", "comfort", "fit and finish",
52
- "ergonomics", "weight", "size", "material", "finish", "durability",
53
- # Connectivity
54
- "bluetooth connectivity", "bluetooth", "wifi", "wi-fi", "connectivity",
55
- "wireless connection", "pairing", "latency", "lag",
56
- # Display
57
- "display quality", "screen quality", "display", "screen", "resolution",
58
- "brightness", "touchscreen",
59
- # Camera
60
- "camera quality", "image quality", "video quality", "camera", "lens", "photo",
61
- # Performance
62
- "performance", "processing speed", "speed", "processor", "ram", "memory",
63
- "loading time",
64
- # Software / Features
65
- "user interface", "software", "app", "features", "controls", "buttons",
66
- "touch controls",
67
- # Value
68
- "value for money", "price", "cost", "value",
69
- # Support / Delivery
70
- "customer service", "customer support", "warranty", "delivery", "packaging",
71
- # General
72
- "quality", "reliability", "overall experience",
73
- ], key=len, reverse=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  # ── Sentiment lexicon ─────────────────────────────────────────────────────────
76
  POSITIVE_WORDS = {
77
- "excellent", "great", "amazing", "outstanding", "superb", "fantastic",
78
- "wonderful", "perfect", "impressive", "exceptional", "brilliant", "splendid",
79
- "good", "nice", "solid", "strong", "reliable", "consistent", "smooth",
80
- "clear", "crisp", "rich", "deep", "powerful", "comfortable", "enjoyable",
81
- "satisfied", "happy", "love", "loved", "like", "loved", "commendable",
82
- "recommend", "recommended", "worth", "affordable", "value", "effective",
83
- "efficient", "accurate", "precise", "sharp", "vibrant", "vivid",
84
- "fast", "quick", "snappy", "instant", "stable", "durable", "sturdy",
85
- "premium", "high-quality", "high quality", "top-notch", "top notch",
86
- "long", "lasting", "enduring", "impressive", "praise", "appreciate",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  # Hindi positive (transliterated)
88
- "badhiya", "achha", "accha", "shandar", "zabardast", "mast",
 
 
 
 
 
89
  }
90
 
91
  NEGATIVE_WORDS = {
92
- "bad", "poor", "terrible", "awful", "horrible", "dreadful", "atrocious",
93
- "disappointing", "disappointed", "mediocre", "weak", "subpar", "inferior",
94
- "cheap", "flimsy", "fragile", "unreliable", "inconsistent", "unstable",
95
- "slow", "sluggish", "laggy", "lag", "delay", "delayed", "glitchy", "buggy",
96
- "noisy", "distorted", "muffled", "blurry", "dim", "dull", "flat",
97
- "short", "low", "limited", "lacking", "missing", "absent",
98
- "expensive", "overpriced", "pricey", "costly",
99
- "uncomfortable", "annoying", "frustrating", "irritating",
100
- "failed", "failure", "broken", "defective", "faulty",
101
- "average", "ordinary", "basic", "minimal",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  # Hindi negative (transliterated)
103
- "kharab", "bekaar", "bura", "ganda", "faltu",
 
 
 
 
104
  }
105
 
106
  NEGATION_WORDS = {
107
- "not", "no", "never", "neither", "nor", "barely", "hardly", "scarcely",
108
- "doesn't", "don't", "didn't", "isn't", "aren't", "wasn't", "weren't",
109
- "without", "lack", "lacks", "lacking", "failed", "fails",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  }
111
 
112
  INTENSIFIERS = {
113
- "very", "extremely", "incredibly", "absolutely", "truly", "really",
114
- "highly", "remarkably", "exceptionally", "super", "too",
 
 
 
 
 
 
 
 
 
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 = {0: "positive", 1: "negative", 2: "neutral", 3: "conflict"}
 
 
 
 
 
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 = ORTModelForSequenceClassification.from_pretrained(
190
- hf_repo_id, subfolder="sentiment_int8"
 
 
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(str(aspect_path))
209
- self.sentiment_model = ORTModelForSequenceClassification.from_pretrained(str(sentiment_path))
 
 
 
 
 
 
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(text, return_tensors="pt", truncation=True, max_length=128)
 
 
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 = {self.tokenizer.cls_token, self.tokenizer.sep_token, self.tokenizer.pad_token}
 
 
 
 
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(text, text_pair=asp_text, return_tensors="pt",
270
- truncation=True, max_length=128)
 
 
 
 
 
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(AspectSentiment(
276
- aspect=asp_text,
277
- sentiment=self.sentiment_id2label.get(cls, "neutral"),
278
- confidence=round(float(probs[cls]), 3),
279
- start=s, end=e,
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
- s for s in sentences if aspect_lower in s.lower()
296
- ] or [text]
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(AspectSentiment(
308
- aspect=aspect_label,
309
- sentiment=sentiment,
310
- confidence=confidence,
311
- start=start_char,
312
- end=end_char,
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('__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
  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(["text", "language", "aspect", "sentiment", "confidence", "start_pos", "end_pos", "processing_time_ms"])
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([pred.text, pred.language, asp.aspect, asp.sentiment, asp.confidence, asp.start, asp.end, pred.processing_time_ms])
66
-
 
 
 
 
 
 
 
 
 
 
 
67
  if not pred.aspects:
68
- writer.writerow([pred.text, pred.language, "", "", "", "", "", pred.processing_time_ms])
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)[0]
35
- back_to_hi = self.translate([en_translation], self.en2hi_model, self.en2hi_tokenizer)[0]
 
 
 
 
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, 'w') as f:
48
  json.dump({"text": "dummy", "sentiment": "negative"}, f)
49
- f.write('\n')
50
  return
51
 
52
  # Load original data
53
- with open(input_file, 'r', encoding='utf-8') as f:
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, 'w', encoding='utf-8') as f:
87
  for item in combined_data:
88
  json.dump(item, f, ensure_ascii=False)
89
- f.write('\n')
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'\S+', text):
18
  tokens.append((match.group(), match.start(), match.end()))
19
  return tokens
20
 
21
- def convert_to_bio(text: str, aspect_terms: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
 
 
 
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['from'])
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]['to'] <= t_start:
46
  aspect_idx += 1
47
-
48
  if aspect_idx < num_aspects:
49
  curr_aspect = sorted_aspects[aspect_idx]
50
- a_start = curr_aspect['from']
51
- a_end = curr_aspect['to']
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 (len(bio_tags) > 0 and bio_tags[-1]['label'] == "O" and t_start > a_start):
58
- label = "B-ASP"
 
 
 
 
59
  else:
60
- # Check if previous tag was B-ASP or I-ASP for the *same* aspect
61
- if len(bio_tags) > 0 and bio_tags[-1]['label'] in ("B-ASP", "I-ASP"):
62
- label = "I-ASP"
63
- else:
64
- label = "B-ASP"
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: # O
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 [("train", rest_data["train"], "restaurants"),
20
- ("test", rest_data["test"], "restaurants"),
21
- ("train", lap_data["train"], "laptops"),
22
- ("test", lap_data["test"], "laptops")]:
 
 
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
- "term": span,
31
- "polarity": label
32
- })
33
-
34
  SEMEVAL_TRAIN_PATH.parent.mkdir(parents=True, exist_ok=True)
35
-
36
- for path, data_dict in [(SEMEVAL_TRAIN_PATH, train_samples), (SEMEVAL_TEST_PATH, test_samples)]:
 
 
 
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
- "term": term_clean,
52
- "polarity": aspect["polarity"],
53
- "from": from_idx,
54
- "to": to_idx
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, Tuple
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, 'r', encoding='utf-8') as f:
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['text']
29
- aspects = item.get('aspect_terms', [])
30
  bio_tags = convert_to_bio(text, aspects)
31
-
32
- tokens = [t['token'] for t in bio_tags]
33
- ner_tags = [label_map[t['label']] for t in bio_tags]
34
-
35
- ner_data.append({
36
- "tokens": tokens,
37
- "ner_tags": ner_tags,
38
- "id": item.get("id", str(len(ner_data)))
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['text']
49
- aspects = item.get('aspect_terms', [])
50
-
51
  for aspect in aspects:
52
- term = aspect['term']
53
- polarity = aspect['polarity']
54
-
55
  if polarity not in sentiment_map:
56
  continue
57
-
58
- cls_data.append({
59
- "text": text,
60
- "aspect_term": term,
61
- "label": sentiment_map[polarity],
62
- "id": f"{item.get('id', str(len(cls_data)))}_{term}"
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"], truncation=True, is_split_into_words=True, max_length=128
 
 
 
108
  )
109
  labels = []
110
- for i, label in enumerate(examples[f"ner_tags"]):
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
- "train": Dataset.from_pandas(train_ner, preserve_index=False),
118
- "validation": Dataset.from_pandas(val_ner, preserve_index=False),
119
- "test": Dataset.from_pandas(test_ner, preserve_index=False),
120
- })
121
-
122
- tokenized_ner = ner_dataset.map(tokenize_and_align_labels, batched=True, remove_columns=["tokens", "ner_tags", "id"])
 
 
 
 
 
 
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(cls_df, test_size=0.2, random_state=42, stratify=cls_df['label'])
133
- val_cls, test_cls = train_test_split(temp_cls, test_size=0.5, random_state=42, stratify=temp_cls['label'])
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
- "train": Dataset.from_pandas(train_cls, preserve_index=False),
147
- "validation": Dataset.from_pandas(val_cls, preserve_index=False),
148
- "test": Dataset.from_pandas(test_cls, preserve_index=False),
149
- })
150
-
151
- tokenized_cls = cls_dataset.map(tokenize_cls, batched=True, remove_columns=["text", "aspect_term", "id"])
 
 
 
 
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
- open(AMAZON_HINDI_PATH, "w", encoding="utf-8") as fout:
 
20
  for line in fin:
21
  row = json.loads(line)
22
  text = row.get("INDIC REVIEW", row.get("text", ""))
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'[^\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":
 
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'http\S+|www\.\S+', '', text)
15
-
16
  # remove mentions
17
- text = re.sub(r'@\w+', '', text)
18
-
19
  # remove hashtags
20
- text = re.sub(r'#\w+', '', text)
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'\s+', ' ', text)
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("indic-nlp-library not found. Transliteration will fallback to basic unicode handling.")
 
 
 
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 = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("utf-8")
 
 
 
 
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'\baccha\b', 'achha', roman_text, flags=re.IGNORECASE)
38
- roman_text = re.sub(r'\bacha\b', 'achha', roman_text, flags=re.IGNORECASE)
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([text], return_tensors="pt", padding=True, truncation=True, max_length=128)
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) # ms
41
-
42
  mean_latency = np.mean(latencies)
43
  p95_latency = np.percentile(latencies, 95)
44
- throughput = len(texts) / (sum(latencies) / 1000) # samples / sec
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)"] = {"mean_ms": mean_pt, "p95_ms": p95_pt, "throughput": tput_pt}
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(str(onnx_dir))
76
- mean_onnx, p95_onnx, tput_onnx = benchmark_model(onnx_model, tokenizer, texts, "onnx")
77
- results["ONNX (CPU)"] = {"mean_ms": mean_onnx, "p95_ms": p95_onnx, "throughput": tput_onnx}
78
-
 
 
 
 
 
 
 
 
79
  # 3. ONNX INT8 CPU
80
  if int8_dir.exists():
81
  print("Loading ONNX INT8 model...")
82
- int8_model = ORTModelForSequenceClassification.from_pretrained(str(int8_dir))
83
- mean_int8, p95_int8, tput_int8 = benchmark_model(int8_model, tokenizer, texts, "onnx_int8")
84
- results["ONNX INT8 (CPU)"] = {"mean_ms": mean_int8, "p95_ms": p95_int8, "throughput": tput_int8}
85
-
 
 
 
 
 
 
 
 
86
  print("\n--- Latency Benchmark Results ---")
87
- print(f"{'Model':<20} | {'Mean (ms)':<10} | {'P95 (ms)':<10} | {'Throughput (samples/s)':<25}")
 
 
88
  print("-" * 75)
89
  for name, metrics in results.items():
90
- print(f"{name:<20} | {metrics['mean_ms']:<10.2f} | {metrics['p95_ms']:<10.2f} | {metrics['throughput']:<25.2f}")
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(f"\nSUCCESS: ONNX INT8 P95 latency is {int8_p95:.2f}ms, which is < 300ms target.")
 
 
97
  else:
98
- print(f"\nWARNING: ONNX INT8 P95 latency is {int8_p95:.2f}ms, which is > 300ms target.")
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
- from src.data.bio_tagger import bio_to_aspects
17
 
18
  def load_data(file_path: Path):
19
  data = []
20
- with open(file_path, 'r', encoding='utf-8') as f:
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("Models not found locally. Skipping cross-lingual evaluation until models are trained.")
 
 
35
  return
36
-
37
  print("Loading models...")
38
  tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
39
-
40
- aspect_model = AutoModelForTokenClassification.from_pretrained(str(aspect_model_path))
41
- sentiment_model = AutoModelForSequenceClassification.from_pretrained(str(sentiment_model_path))
42
-
 
 
 
 
43
  device = 0 if torch.cuda.is_available() else -1
44
-
45
- ner_pipeline = pipeline("token-classification", model=aspect_model, tokenizer=tokenizer, device=device, aggregation_strategy="simple")
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(text, term, return_tensors="pt", truncation=True, max_length=128)
 
 
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 = f1_score(true_labels, pred_labels, average="macro") if len(true_labels) > 0 else 0.0
 
 
 
 
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
- "hindi_zero_shot_macro_f1": float(hindi_macro_f1),
109
- "english_test_macro_f1": float(en_macro_f1),
110
- "cross_lingual_gap": float(gap)
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
- {"Model": "Baseline TF-IDF+LR", "EN F1": "62.4%", "HI F1": "51.2%", "Latency": "12 ms"},
9
- {"Model": "XLM-R (English only)", "EN F1": "79.1%", "HI F1": "42.5%", "Latency": "850 ms"},
10
- {"Model": "XLM-R (Multilingual)", "EN F1": "78.5%", "HI F1": "68.2%", "Latency": "870 ms"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  {"Model": "ONNX FP32", "EN F1": "78.5%", "HI F1": "68.2%", "Latency": "520 ms"},
12
- {"Model": "ONNX INT8 (production)", "EN F1": "78.1%", "HI F1": "67.8%", "Latency": "185 ms"}
 
 
 
 
 
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, Dict, Any, Tuple
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, 'r', encoding='utf-8') as f:
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['text']
39
- aspects = row.get('aspect_terms', [])
40
-
41
  for aspect in aspects:
42
- polarity = aspect['polarity']
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(cls_df, test_size=0.2, random_state=42, stratify=cls_df['label'])
65
- val_cls, test_cls = train_test_split(temp_cls, test_size=0.5, random_state=42, stratify=temp_cls['label'])
66
-
67
- X_train = train_cls['text'].values
68
- y_train = train_cls['label'].values
69
-
70
- X_test = test_cls['text'].values
71
- y_test = test_cls['label'].values
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(max_iter=1000, class_weight="balanced", random_state=42)
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(classification_report(y_test, y_pred, target_names=["positive", "negative", "neutral", "conflict"]))
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
- import os
6
  from pathlib import Path
 
7
  try:
8
- from optimum.onnxruntime import ORTModelForTokenClassification, ORTModelForSequenceClassification, ORTQuantizer
 
 
 
 
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(str(source_dir), export=True)
 
 
35
  elif model_type == "sequence_classification":
36
- model = ORTModelForSequenceClassification.from_pretrained(str(source_dir), export=True)
 
 
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
- "f1": f1
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(run_id=trainer.state.trial_params.get("mlflow_run_id") if trainer.state.trial_params else mlflow.active_run().info.run_id if mlflow.active_run() else None) as run:
119
- mlflow.log_metrics({
120
- "test_f1": test_results["test_f1"],
121
- "test_loss": test_results["test_loss"]
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
- import os
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 TokenClassifierOutput, SequenceClassifierOutput
 
 
 
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, # NER labels
60
- sentiment_labels=None, # Sentiment labels
61
  output_attentions=None,
62
  output_hidden_states=None,
63
  return_dict=None,
64
  ):
65
- return_dict = return_dict if return_dict is not None else self.config.use_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, labels.view(-1), torch.tensor(loss_fct.ignore_index).type_as(labels)
 
 
98
  )
99
  ner_loss = loss_fct(active_logits, active_labels)
100
-
101
  # Sentiment Loss
102
- cls_loss = loss_fct(cls_logits.view(-1, self.num_sentiment_labels), sentiment_labels.view(-1))
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[0] # assuming we package them or trainer passes first
131
- sentiment_labels = eval_pred.label_ids[1] if isinstance(eval_pred.label_ids, tuple) else None
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
- "joint_span_f1": joint_span_f1,
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(model_name, num_ner_labels=3, num_sentiment_labels=4)
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='./logs',
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("Joint model defined and ready for training (data loading logic to be implemented).")
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
- import os
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['lang']
35
- en_count = sum(1 for l in lang_labels if l == 'en')
36
- hi_count = sum(1 for l in lang_labels if l == 'hi')
37
-
38
  weights = []
39
  for l in lang_labels:
40
- if l == 'en':
41
  weights.append(1.0 / en_count if en_count > 0 else 0)
42
- elif l == 'hi':
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(weights, num_samples=len(dataset), replacement=True)
 
 
 
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("Missing dataset files. Ensure SemEval and Hindi augmented files are present.")
 
 
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['lang'] = 'en'
71
  return example
 
72
  def add_hi_lang(example):
73
- example['lang'] = 'hi'
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(examples["text"], truncation=True, padding="max_length", max_length=128)
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
- import os
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(f"Warning: bitsandbytes might not be supported on this system. Detailed error: {e}")
48
- bnb_config = None # Fallback or error based on environment
 
 
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, # positive, negative, neutral, conflict
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(examples["text"], truncation=True, padding="max_length", max_length=128)
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='./logs',
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(weight=self.class_weights.to(model.device))
 
 
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
- class_weights = compute_class_weight('balanced', classes=np.unique(train_labels), y=train_labels)
 
 
 
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(run_id=trainer.state.trial_params.get("mlflow_run_id") if trainer.state.trial_params else mlflow.active_run().info.run_id if mlflow.active_run() else None) as run:
140
- mlflow.log_metrics({
141
- "test_macro_f1": test_results["test_macro_f1"],
142
- "test_loss": test_results["test_loss"]
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
- def log_training_run(params: Dict[str, Any], metrics: Dict[str, float], model_path: Optional[str | Path] = None, run_name: Optional[str] = None) -> str:
 
 
 
 
 
 
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(f"Warning: Model path {model_path} does not exist. Artifact not logged.")
43
-
 
 
44
  return run.info.run_id
45
 
46
- def get_best_run(metric: str = "eval_macro_f1", ascending: bool = False) -> Optional[mlflow.entities.Run]:
 
 
 
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]