Text Generation
Transformers
Burmese
English
myanmar
burmese
llm
chat
instruction-following
conversational
autoregressive
Instructions to use amkyawdev/myanmar-ghost with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use amkyawdev/myanmar-ghost with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="amkyawdev/myanmar-ghost") messages = [ {"role": "user", "content": "Who are you?"}, ] pipe(messages)# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("amkyawdev/myanmar-ghost", device_map="auto") - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use amkyawdev/myanmar-ghost with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "amkyawdev/myanmar-ghost" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "amkyawdev/myanmar-ghost", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker
docker model run hf.co/amkyawdev/myanmar-ghost
- SGLang
How to use amkyawdev/myanmar-ghost with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "amkyawdev/myanmar-ghost" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "amkyawdev/myanmar-ghost", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "amkyawdev/myanmar-ghost" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "amkyawdev/myanmar-ghost", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }' - Docker Model Runner
How to use amkyawdev/myanmar-ghost with Docker Model Runner:
docker model run hf.co/amkyawdev/myanmar-ghost
Add source code
Browse files- __init__.py +0 -0
- annotation/__init__.py +0 -0
- annotation/active_learning/__init__.py +0 -0
- annotation/active_learning/human_feedback_loop.py +338 -0
- annotation/active_learning/uncertainty_sampler.py +342 -0
- annotation/automatic_verifier.py +387 -0
- annotation/labeler_tool.py +333 -0
- augmentation/__init__.py +0 -0
- augmentation/back_translator.py +242 -0
- augmentation/perturbator.py +368 -0
- augmentation/synonym_replacer.py +227 -0
- data_processing/__init__.py +0 -0
- data_processing/audio_processor.py +215 -0
- data_processing/graph_builder.py +466 -0
- data_processing/multimodal_fusion.py +371 -0
- data_processing/text_normalizer.py +210 -0
- federated/__init__.py +0 -0
- federated/aggregator.py +283 -0
- federated/client.py +272 -0
- federated/server.py +205 -0
- models/__init__.py +0 -0
- models/base_model.py +106 -0
- models/evaluate.py +252 -0
- models/multimodal_model.py +192 -0
- models/train.py +318 -0
- models/transformer_model.py +175 -0
- pipelines/__init__.py +0 -0
- pipelines/data_pipeline.py +193 -0
- pipelines/deployment_pipeline.py +234 -0
- pipelines/evaluation_pipeline.py +197 -0
- utils/__init__.py +19 -0
- utils/file_utils.py +142 -0
- utils/logger.py +139 -0
- utils/metrics.py +176 -0
- utils/visualization.py +270 -0
- xai/__init__.py +0 -0
- xai/lime_explainer.py +313 -0
- xai/shap_explainer.py +410 -0
- xai/visualization.py +360 -0
__init__.py
ADDED
|
File without changes
|
annotation/__init__.py
ADDED
|
File without changes
|
annotation/active_learning/__init__.py
ADDED
|
File without changes
|
annotation/active_learning/human_feedback_loop.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Human feedback loop for active learning.
|
| 2 |
+
|
| 3 |
+
Manages the cycle of:
|
| 4 |
+
1. Model prediction
|
| 5 |
+
2. Uncertainty sampling
|
| 6 |
+
3. Human annotation
|
| 7 |
+
4. Model retraining
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 16 |
+
|
| 17 |
+
import pandas as pd
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class FeedbackRecord:
|
| 24 |
+
"""Record of human feedback for a sample."""
|
| 25 |
+
sample_id: str
|
| 26 |
+
text: str
|
| 27 |
+
original_prediction: str
|
| 28 |
+
human_label: str
|
| 29 |
+
confidence_feedback: float # 0-1, did model seem confident?
|
| 30 |
+
notes: str = ""
|
| 31 |
+
timestamp: str = ""
|
| 32 |
+
|
| 33 |
+
def to_dict(self) -> Dict:
|
| 34 |
+
return {
|
| 35 |
+
"sample_id": self.sample_id,
|
| 36 |
+
"text": self.text,
|
| 37 |
+
"original_prediction": self.original_prediction,
|
| 38 |
+
"human_label": self.human_label,
|
| 39 |
+
"confidence_feedback": self.confidence_feedback,
|
| 40 |
+
"notes": self.notes,
|
| 41 |
+
"timestamp": self.timestamp or datetime.now().isoformat(),
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class FeedbackLoopConfig:
|
| 47 |
+
"""Configuration for feedback loop."""
|
| 48 |
+
min_feedback_samples: int = 50
|
| 49 |
+
max_feedback_samples: int = 500
|
| 50 |
+
retrain_threshold: int = 100 # Retrain after this many new samples
|
| 51 |
+
disagreement_threshold: float = 0.3 # Retrain if disagreement rate > this
|
| 52 |
+
batch_size: int = 32
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass
|
| 56 |
+
class LoopState:
|
| 57 |
+
"""State of the feedback loop."""
|
| 58 |
+
iteration: int = 0
|
| 59 |
+
total_annotated: int = 0
|
| 60 |
+
total_retrained: int = 0
|
| 61 |
+
disagreement_rate: float = 0.0
|
| 62 |
+
model_performance: Dict = field(default_factory=dict)
|
| 63 |
+
history: List[Dict] = field(default_factory=list)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class HumanFeedbackLoop:
|
| 67 |
+
"""Manages the human-in-the-loop training cycle."""
|
| 68 |
+
|
| 69 |
+
def __init__(
|
| 70 |
+
self,
|
| 71 |
+
config: Optional[FeedbackLoopConfig] = None,
|
| 72 |
+
output_dir: str = "outputs/active_learning",
|
| 73 |
+
):
|
| 74 |
+
self.config = config or FeedbackLoopConfig()
|
| 75 |
+
self.output_dir = Path(output_dir)
|
| 76 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
|
| 78 |
+
self.state = LoopState()
|
| 79 |
+
self.feedback_records: List[FeedbackRecord] = []
|
| 80 |
+
self.labeled_samples: List[Dict] = []
|
| 81 |
+
|
| 82 |
+
def add_feedback(
|
| 83 |
+
self,
|
| 84 |
+
sample_id: str,
|
| 85 |
+
text: str,
|
| 86 |
+
original_prediction: str,
|
| 87 |
+
human_label: str,
|
| 88 |
+
confidence_feedback: float = 0.5,
|
| 89 |
+
notes: str = "",
|
| 90 |
+
) -> None:
|
| 91 |
+
"""Add human feedback for a sample."""
|
| 92 |
+
record = FeedbackRecord(
|
| 93 |
+
sample_id=sample_id,
|
| 94 |
+
text=text,
|
| 95 |
+
original_prediction=original_prediction,
|
| 96 |
+
human_label=human_label,
|
| 97 |
+
confidence_feedback=confidence_feedback,
|
| 98 |
+
notes=notes,
|
| 99 |
+
timestamp=datetime.now().isoformat(),
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
self.feedback_records.append(record)
|
| 103 |
+
|
| 104 |
+
# Add to labeled samples
|
| 105 |
+
self.labeled_samples.append({
|
| 106 |
+
"id": sample_id,
|
| 107 |
+
"text": text,
|
| 108 |
+
"label": human_label,
|
| 109 |
+
"source": "human_feedback",
|
| 110 |
+
})
|
| 111 |
+
|
| 112 |
+
self.state.total_annotated += 1
|
| 113 |
+
|
| 114 |
+
logger.info(
|
| 115 |
+
f"Added feedback for {sample_id}: "
|
| 116 |
+
f"{original_prediction} -> {human_label}"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
def batch_add_feedback(
|
| 120 |
+
self,
|
| 121 |
+
feedback_list: List[Dict],
|
| 122 |
+
) -> None:
|
| 123 |
+
"""Add multiple feedback records at once."""
|
| 124 |
+
for fb in feedback_list:
|
| 125 |
+
self.add_feedback(
|
| 126 |
+
sample_id=fb.get("sample_id", fb.get("id")),
|
| 127 |
+
text=fb.get("text", ""),
|
| 128 |
+
original_prediction=fb.get("original_prediction", "unknown"),
|
| 129 |
+
human_label=fb.get("human_label", fb.get("label")),
|
| 130 |
+
confidence_feedback=fb.get("confidence_feedback", 0.5),
|
| 131 |
+
notes=fb.get("notes", ""),
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
def should_retrain(self) -> Tuple[bool, str]:
|
| 135 |
+
"""Check if model should be retrained.
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
(should_retrain, reason)
|
| 139 |
+
"""
|
| 140 |
+
n_new = len(self.feedback_records)
|
| 141 |
+
|
| 142 |
+
# Check minimum samples
|
| 143 |
+
if n_new < self.config.min_feedback_samples:
|
| 144 |
+
return False, f"Only {n_new} samples (min: {self.config.min_feedback_samples})"
|
| 145 |
+
|
| 146 |
+
# Check retrain threshold
|
| 147 |
+
if n_new >= self.config.retrain_threshold:
|
| 148 |
+
self._calculate_disagreement_rate()
|
| 149 |
+
if self.state.disagreement_rate > self.config.disagreement_threshold:
|
| 150 |
+
return True, f"High disagreement ({self.state.disagreement_rate:.1%})"
|
| 151 |
+
return True, f"Reached {n_new} samples threshold"
|
| 152 |
+
|
| 153 |
+
return False, f"Not enough samples: {n_new}"
|
| 154 |
+
|
| 155 |
+
def _calculate_disagreement_rate(self) -> float:
|
| 156 |
+
"""Calculate disagreement rate between model and human."""
|
| 157 |
+
if not self.feedback_records:
|
| 158 |
+
self.state.disagreement_rate = 0.0
|
| 159 |
+
return 0.0
|
| 160 |
+
|
| 161 |
+
disagreements = sum(
|
| 162 |
+
1 for r in self.feedback_records
|
| 163 |
+
if r.original_prediction != r.human_label
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
self.state.disagreement_rate = disagreements / len(self.feedback_records)
|
| 167 |
+
return self.state.disagreement_rate
|
| 168 |
+
|
| 169 |
+
def get_training_data(
|
| 170 |
+
self,
|
| 171 |
+
include_previous: bool = True,
|
| 172 |
+
) -> List[Dict]:
|
| 173 |
+
"""Get accumulated training data.
|
| 174 |
+
|
| 175 |
+
Args:
|
| 176 |
+
include_previous: Include previously retrained data
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
List of samples with labels
|
| 180 |
+
"""
|
| 181 |
+
if include_previous:
|
| 182 |
+
return self.labeled_samples
|
| 183 |
+
else:
|
| 184 |
+
# Only return new samples since last retrain
|
| 185 |
+
return self.labeled_samples[-self.config.retrain_threshold:]
|
| 186 |
+
|
| 187 |
+
def export_training_data(
|
| 188 |
+
self,
|
| 189 |
+
path: Optional[str] = None,
|
| 190 |
+
format: str = "jsonl",
|
| 191 |
+
) -> str:
|
| 192 |
+
"""Export training data to file."""
|
| 193 |
+
if path is None:
|
| 194 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 195 |
+
path = self.output_dir / f"training_data_{timestamp}.{format}"
|
| 196 |
+
|
| 197 |
+
if format == "jsonl":
|
| 198 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 199 |
+
for sample in self.labeled_samples:
|
| 200 |
+
f.write(json.dumps(sample, ensure_ascii=False) + "\n")
|
| 201 |
+
elif format == "csv":
|
| 202 |
+
df = pd.DataFrame(self.labeled_samples)
|
| 203 |
+
df.to_csv(path, index=False)
|
| 204 |
+
|
| 205 |
+
logger.info(f"Exported {len(self.labeled_samples)} samples to {path}")
|
| 206 |
+
return str(path)
|
| 207 |
+
|
| 208 |
+
def mark_retrained(self, performance: Optional[Dict] = None) -> None:
|
| 209 |
+
"""Mark that retraining has occurred."""
|
| 210 |
+
self.state.iteration += 1
|
| 211 |
+
self.state.total_retrained += 1
|
| 212 |
+
|
| 213 |
+
if performance:
|
| 214 |
+
self.state.model_performance = performance
|
| 215 |
+
|
| 216 |
+
# Record history
|
| 217 |
+
self.history.append({
|
| 218 |
+
"iteration": self.state.iteration,
|
| 219 |
+
"timestamp": datetime.now().isoformat(),
|
| 220 |
+
"total_annotated": self.state.total_annotated,
|
| 221 |
+
"disagreement_rate": self.state.disagreement_rate,
|
| 222 |
+
"performance": performance,
|
| 223 |
+
})
|
| 224 |
+
|
| 225 |
+
logger.info(
|
| 226 |
+
f"Model retrained (iteration {self.state.iteration}). "
|
| 227 |
+
f"Total annotated: {self.state.total_annotated}"
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
def get_statistics(self) -> Dict[str, Any]:
|
| 231 |
+
"""Get loop statistics."""
|
| 232 |
+
return {
|
| 233 |
+
"iteration": self.state.iteration,
|
| 234 |
+
"total_annotated": self.state.total_annotated,
|
| 235 |
+
"total_retrained": self.state.total_retrained,
|
| 236 |
+
"disagreement_rate": self.state.disagreement_rate,
|
| 237 |
+
"should_retrain": self.should_retrain()[0],
|
| 238 |
+
"pending_samples": len(self.feedback_records),
|
| 239 |
+
"recent_history": self.history[-5:] if self.history else [],
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
def get_label_distribution(self) -> Dict[str, int]:
|
| 243 |
+
"""Get distribution of labels."""
|
| 244 |
+
from collections import Counter
|
| 245 |
+
labels = [r.human_label for r in self.feedback_records]
|
| 246 |
+
return dict(Counter(labels))
|
| 247 |
+
|
| 248 |
+
def analyze_errors(self) -> Dict[str, Any]:
|
| 249 |
+
"""Analyze patterns in model errors."""
|
| 250 |
+
errors = [
|
| 251 |
+
r for r in self.feedback_records
|
| 252 |
+
if r.original_prediction != r.human_label
|
| 253 |
+
]
|
| 254 |
+
|
| 255 |
+
if not errors:
|
| 256 |
+
return {"total_errors": 0}
|
| 257 |
+
|
| 258 |
+
# Group by confusion pairs
|
| 259 |
+
confusion_pairs = {}
|
| 260 |
+
for e in errors:
|
| 261 |
+
pair = (e.original_prediction, e.human_label)
|
| 262 |
+
confusion_pairs[pair] = confusion_pairs.get(pair, 0) + 1
|
| 263 |
+
|
| 264 |
+
return {
|
| 265 |
+
"total_errors": len(errors),
|
| 266 |
+
"error_rate": len(errors) / len(self.feedback_records),
|
| 267 |
+
"confusion_matrix": confusion_pairs,
|
| 268 |
+
"most_common_error": max(
|
| 269 |
+
confusion_pairs.items(),
|
| 270 |
+
key=lambda x: x[1]
|
| 271 |
+
) if confusion_pairs else None,
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
def save_state(self, path: Optional[str] = None) -> str:
|
| 275 |
+
"""Save loop state to file."""
|
| 276 |
+
if path is None:
|
| 277 |
+
path = self.output_dir / "loop_state.json"
|
| 278 |
+
|
| 279 |
+
state_data = {
|
| 280 |
+
"config": {
|
| 281 |
+
"min_feedback_samples": self.config.min_feedback_samples,
|
| 282 |
+
"max_feedback_samples": self.config.max_feedback_samples,
|
| 283 |
+
"retrain_threshold": self.config.retrain_threshold,
|
| 284 |
+
"disagreement_threshold": self.config.disagreement_threshold,
|
| 285 |
+
},
|
| 286 |
+
"state": {
|
| 287 |
+
"iteration": self.state.iteration,
|
| 288 |
+
"total_annotated": self.state.total_annotated,
|
| 289 |
+
"total_retrained": self.state.total_retrained,
|
| 290 |
+
"disagreement_rate": self.state.disagreement_rate,
|
| 291 |
+
},
|
| 292 |
+
"history": self.history,
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 296 |
+
json.dump(state_data, f, indent=2)
|
| 297 |
+
|
| 298 |
+
return str(path)
|
| 299 |
+
|
| 300 |
+
def load_state(self, path: str) -> None:
|
| 301 |
+
"""Load loop state from file."""
|
| 302 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 303 |
+
state_data = json.load(f)
|
| 304 |
+
|
| 305 |
+
config_dict = state_data.get("config", {})
|
| 306 |
+
self.config = FeedbackLoopConfig(**config_dict)
|
| 307 |
+
|
| 308 |
+
state_dict = state_data.get("state", {})
|
| 309 |
+
self.state = LoopState(**state_dict)
|
| 310 |
+
|
| 311 |
+
self.history = state_data.get("history", [])
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def create_feedback_loop(
|
| 315 |
+
config: Optional[Dict] = None,
|
| 316 |
+
) -> HumanFeedbackLoop:
|
| 317 |
+
"""Factory function to create feedback loop."""
|
| 318 |
+
loop_config = None
|
| 319 |
+
if config:
|
| 320 |
+
loop_config = FeedbackLoopConfig(**config)
|
| 321 |
+
|
| 322 |
+
return HumanFeedbackLoop(config=loop_config)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
if __name__ == "__main__":
|
| 326 |
+
loop = create_feedback_loop()
|
| 327 |
+
|
| 328 |
+
# Simulate feedback
|
| 329 |
+
loop.add_feedback(
|
| 330 |
+
sample_id="utt_001",
|
| 331 |
+
text="ကျေးဇူးပါ",
|
| 332 |
+
original_prediction="positive",
|
| 333 |
+
human_label="sarcastic",
|
| 334 |
+
notes="Voice tone suggests complaint",
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
print(f"Should retrain: {loop.should_retrain()}")
|
| 338 |
+
print(f"Stats: {loop.get_statistics()}")
|
annotation/active_learning/uncertainty_sampler.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Uncertainty sampling for active learning.
|
| 2 |
+
|
| 3 |
+
Selects samples where the model has lowest confidence,
|
| 4 |
+
indicating areas where human annotation would be most valuable.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from enum import Enum
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn as nn
|
| 17 |
+
from torch.utils.data import DataLoader, Dataset
|
| 18 |
+
from tqdm import tqdm
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class UncertaintyMethod(str, Enum):
|
| 24 |
+
"""Methods for calculating uncertainty."""
|
| 25 |
+
LEAST_CONFIDENCE = "least_confidence" # 1 - max probability
|
| 26 |
+
MARGIN = "margin" # Difference between top 2 probabilities
|
| 27 |
+
ENTROPY = "entropy" # Shannon entropy
|
| 28 |
+
RATIO = "ratio" # Ratio of top to second probability
|
| 29 |
+
VARIANCE = "variance" # Prediction variance (ensemble)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class UncertaintySample:
|
| 34 |
+
"""Sample with uncertainty score."""
|
| 35 |
+
sample_id: str
|
| 36 |
+
text: str
|
| 37 |
+
uncertainty_score: float
|
| 38 |
+
predicted_class: str
|
| 39 |
+
predicted_prob: float
|
| 40 |
+
second_prob: float = 0.0
|
| 41 |
+
metadata: Dict = None
|
| 42 |
+
|
| 43 |
+
def to_dict(self) -> Dict:
|
| 44 |
+
return {
|
| 45 |
+
"sample_id": self.sample_id,
|
| 46 |
+
"text": self.text,
|
| 47 |
+
"uncertainty_score": self.uncertainty_score,
|
| 48 |
+
"predicted_class": self.predicted_class,
|
| 49 |
+
"predicted_prob": self.predicted_prob,
|
| 50 |
+
"second_prob": self.second_prob,
|
| 51 |
+
"metadata": self.metadata or {},
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class PredictionDataset(Dataset):
|
| 56 |
+
"""Dataset for model predictions."""
|
| 57 |
+
|
| 58 |
+
def __init__(self, samples: List[Dict], tokenizer, max_length: int = 128):
|
| 59 |
+
self.samples = samples
|
| 60 |
+
self.tokenizer = tokenizer
|
| 61 |
+
self.max_length = max_length
|
| 62 |
+
|
| 63 |
+
def __len__(self) -> int:
|
| 64 |
+
return len(self.samples)
|
| 65 |
+
|
| 66 |
+
def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor, str]:
|
| 67 |
+
sample = self.samples[idx]
|
| 68 |
+
text = sample.get("text", "")
|
| 69 |
+
|
| 70 |
+
encoding = self.tokenizer(
|
| 71 |
+
text,
|
| 72 |
+
truncation=True,
|
| 73 |
+
max_length=self.max_length,
|
| 74 |
+
padding="max_length",
|
| 75 |
+
return_tensors="pt",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
encoding["input_ids"].squeeze(0),
|
| 80 |
+
encoding["attention_mask"].squeeze(0),
|
| 81 |
+
sample.get("id", f"sample_{idx}"),
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class UncertaintySampler:
|
| 86 |
+
"""Sample uncertain instances for active learning."""
|
| 87 |
+
|
| 88 |
+
def __init__(
|
| 89 |
+
self,
|
| 90 |
+
model: nn.Module,
|
| 91 |
+
method: UncertaintyMethod = UncertaintyMethod.LEAST_CONFIDENCE,
|
| 92 |
+
device: str = "cuda" if torch.cuda.is_available() else "cpu",
|
| 93 |
+
):
|
| 94 |
+
self.model = model
|
| 95 |
+
self.method = method
|
| 96 |
+
self.device = device
|
| 97 |
+
self.model.to(device)
|
| 98 |
+
self.model.eval()
|
| 99 |
+
|
| 100 |
+
def _compute_uncertainty(
|
| 101 |
+
self,
|
| 102 |
+
logits: torch.Tensor,
|
| 103 |
+
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 104 |
+
"""Compute uncertainty scores from logits.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
uncertainty: Uncertainty scores
|
| 108 |
+
predicted_class: Predicted class indices
|
| 109 |
+
probs: Probability distributions
|
| 110 |
+
second_probs: Second highest probabilities (for margin)
|
| 111 |
+
"""
|
| 112 |
+
probs = torch.softmax(logits, dim=-1)
|
| 113 |
+
|
| 114 |
+
if self.method == UncertaintyMethod.ENTROPY:
|
| 115 |
+
# Shannon entropy: -sum(p * log(p))
|
| 116 |
+
entropy = -torch.sum(probs * torch.log(probs + 1e-10), dim=-1)
|
| 117 |
+
uncertainty = entropy
|
| 118 |
+
elif self.method == UncertaintyMethod.LEAST_CONFIDENCE:
|
| 119 |
+
# 1 - max probability
|
| 120 |
+
max_prob, _ = probs.max(dim=-1)
|
| 121 |
+
uncertainty = 1 - max_prob
|
| 122 |
+
elif self.method == UncertaintyMethod.MARGIN:
|
| 123 |
+
# Difference between top 2 probabilities
|
| 124 |
+
sorted_probs, _ = torch.sort(probs, dim=-1, descending=True)
|
| 125 |
+
margin = sorted_probs[:, 0] - sorted_probs[:, 1]
|
| 126 |
+
uncertainty = 1 - margin
|
| 127 |
+
elif self.method == UncertaintyMethod.RATIO:
|
| 128 |
+
# Ratio of top to second probability
|
| 129 |
+
sorted_probs, _ = torch.sort(probs, dim=-1, descending=True)
|
| 130 |
+
ratio = sorted_probs[:, 1] / (sorted_probs[:, 0] + 1e-10)
|
| 131 |
+
uncertainty = ratio
|
| 132 |
+
else:
|
| 133 |
+
raise ValueError(f"Unknown method: {self.method}")
|
| 134 |
+
|
| 135 |
+
predicted_class = probs.argmax(dim=-1)
|
| 136 |
+
max_probs = probs.max(dim=-1).values
|
| 137 |
+
|
| 138 |
+
# Second highest probability
|
| 139 |
+
sorted_probs_detached = probs.detach().cpu()
|
| 140 |
+
sorted_indices = torch.argsort(sorted_probs_detached, dim=-1, descending=True)
|
| 141 |
+
second_probs = torch.gather(
|
| 142 |
+
probs, 1, sorted_indices[:, 1:2]
|
| 143 |
+
).squeeze(-1)
|
| 144 |
+
|
| 145 |
+
return uncertainty, predicted_class, max_probs, second_probs
|
| 146 |
+
|
| 147 |
+
def score_samples(
|
| 148 |
+
self,
|
| 149 |
+
samples: List[Dict],
|
| 150 |
+
tokenizer,
|
| 151 |
+
batch_size: int = 32,
|
| 152 |
+
) -> List[UncertaintySample]:
|
| 153 |
+
"""Score samples by uncertainty."""
|
| 154 |
+
dataset = PredictionDataset(samples, tokenizer)
|
| 155 |
+
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=False)
|
| 156 |
+
|
| 157 |
+
all_uncertainty = []
|
| 158 |
+
all_predicted = []
|
| 159 |
+
all_probs = []
|
| 160 |
+
all_second_probs = []
|
| 161 |
+
all_ids = []
|
| 162 |
+
|
| 163 |
+
with torch.no_grad():
|
| 164 |
+
for input_ids, attention_mask, sample_ids in tqdm(
|
| 165 |
+
dataloader, desc="Computing uncertainty"
|
| 166 |
+
):
|
| 167 |
+
input_ids = input_ids.to(self.device)
|
| 168 |
+
attention_mask = attention_mask.to(self.device)
|
| 169 |
+
|
| 170 |
+
outputs = self.model(input_ids, attention_mask)
|
| 171 |
+
logits = outputs.logits if hasattr(outputs, "logits") else outputs
|
| 172 |
+
|
| 173 |
+
uncertainty, pred_class, probs, second_probs = self._compute_uncertainty(logits)
|
| 174 |
+
|
| 175 |
+
all_uncertainty.extend(uncertainty.cpu().tolist())
|
| 176 |
+
all_predicted.extend(pred_class.cpu().tolist())
|
| 177 |
+
all_probs.extend(probs.cpu().tolist())
|
| 178 |
+
all_second_probs.extend(second_probs.cpu().tolist())
|
| 179 |
+
all_ids.extend(sample_ids)
|
| 180 |
+
|
| 181 |
+
# Create uncertainty samples
|
| 182 |
+
uncertain_samples = []
|
| 183 |
+
class_names = ["negative", "neutral", "positive", "sarcastic"]
|
| 184 |
+
|
| 185 |
+
for i, sample in enumerate(samples):
|
| 186 |
+
us = UncertaintySample(
|
| 187 |
+
sample_id=all_ids[i],
|
| 188 |
+
text=sample.get("text", ""),
|
| 189 |
+
uncertainty_score=all_uncertainty[i],
|
| 190 |
+
predicted_class=class_names[all_predicted[i]] if all_predicted[i] < len(class_names) else "unknown",
|
| 191 |
+
predicted_prob=all_probs[i],
|
| 192 |
+
second_prob=all_second_probs[i],
|
| 193 |
+
metadata=sample,
|
| 194 |
+
)
|
| 195 |
+
uncertain_samples.append(us)
|
| 196 |
+
|
| 197 |
+
return uncertain_samples
|
| 198 |
+
|
| 199 |
+
def select_samples(
|
| 200 |
+
self,
|
| 201 |
+
samples: List[Dict],
|
| 202 |
+
tokenizer,
|
| 203 |
+
n_samples: int = 100,
|
| 204 |
+
batch_size: int = 32,
|
| 205 |
+
exclude_ids: Optional[List[str]] = None,
|
| 206 |
+
) -> List[UncertaintySample]:
|
| 207 |
+
"""Select most uncertain samples for annotation.
|
| 208 |
+
|
| 209 |
+
Args:
|
| 210 |
+
samples: List of samples to score
|
| 211 |
+
tokenizer: Tokenizer for the model
|
| 212 |
+
n_samples: Number of samples to select
|
| 213 |
+
batch_size: Batch size for inference
|
| 214 |
+
exclude_ids: Sample IDs to exclude (already annotated)
|
| 215 |
+
|
| 216 |
+
Returns:
|
| 217 |
+
List of selected uncertain samples, sorted by uncertainty
|
| 218 |
+
"""
|
| 219 |
+
# Filter out already annotated
|
| 220 |
+
if exclude_ids:
|
| 221 |
+
samples = [s for s in samples if s.get("id") not in exclude_ids]
|
| 222 |
+
|
| 223 |
+
# Score all samples
|
| 224 |
+
uncertain_samples = self.score_samples(samples, tokenizer, batch_size)
|
| 225 |
+
|
| 226 |
+
# Sort by uncertainty (highest first)
|
| 227 |
+
uncertain_samples.sort(key=lambda x: x.uncertainty_score, reverse=True)
|
| 228 |
+
|
| 229 |
+
# Select top n_samples
|
| 230 |
+
selected = uncertain_samples[:n_samples]
|
| 231 |
+
|
| 232 |
+
logger.info(
|
| 233 |
+
f"Selected {len(selected)} most uncertain samples "
|
| 234 |
+
f"(uncertainty range: {selected[0].uncertainty_score:.4f} - "
|
| 235 |
+
f"{selected[-1].uncertainty_score:.4f})"
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
return selected
|
| 239 |
+
|
| 240 |
+
def diversity_sample(
|
| 241 |
+
self,
|
| 242 |
+
samples: List[Dict],
|
| 243 |
+
tokenizer,
|
| 244 |
+
n_samples: int = 100,
|
| 245 |
+
batch_size: int = 32,
|
| 246 |
+
n_clusters: int = 10,
|
| 247 |
+
) -> List[UncertaintySample]:
|
| 248 |
+
"""Select diverse uncertain samples using clustering.
|
| 249 |
+
|
| 250 |
+
Combines uncertainty with diversity to avoid selecting
|
| 251 |
+
similar samples.
|
| 252 |
+
"""
|
| 253 |
+
from sklearn.cluster import MiniBatchKMeans
|
| 254 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 255 |
+
|
| 256 |
+
# Score samples
|
| 257 |
+
uncertain_samples = self.score_samples(samples, tokenizer, batch_size)
|
| 258 |
+
|
| 259 |
+
# Create embeddings for clustering
|
| 260 |
+
vectorizer = TfidfVectorizer(max_features=1000)
|
| 261 |
+
texts = [s.text for s in samples]
|
| 262 |
+
embeddings = vectorizer.fit_transform(texts)
|
| 263 |
+
|
| 264 |
+
# Cluster
|
| 265 |
+
kmeans = MiniBatchKMeans(n_clusters=n_clusters, random_state=42)
|
| 266 |
+
cluster_labels = kmeans.fit_predict(embeddings)
|
| 267 |
+
|
| 268 |
+
# Select from each cluster
|
| 269 |
+
selected = []
|
| 270 |
+
for cluster_id in range(n_clusters):
|
| 271 |
+
cluster_indices = [
|
| 272 |
+
i for i, label in enumerate(cluster_labels)
|
| 273 |
+
if label == cluster_id
|
| 274 |
+
]
|
| 275 |
+
cluster_uncertain = [
|
| 276 |
+
uncertain_samples[i] for i in cluster_indices
|
| 277 |
+
]
|
| 278 |
+
cluster_uncertain.sort(key=lambda x: x.uncertainty_score, reverse=True)
|
| 279 |
+
|
| 280 |
+
# Take top samples from each cluster
|
| 281 |
+
n_per_cluster = max(1, n_samples // n_clusters)
|
| 282 |
+
selected.extend(cluster_uncertain[:n_per_cluster])
|
| 283 |
+
|
| 284 |
+
# Sort by uncertainty
|
| 285 |
+
selected.sort(key=lambda x: x.uncertainty_score, reverse=True)
|
| 286 |
+
|
| 287 |
+
return selected[:n_samples]
|
| 288 |
+
|
| 289 |
+
def batch_sample(
|
| 290 |
+
self,
|
| 291 |
+
samples: List[Dict],
|
| 292 |
+
tokenizer,
|
| 293 |
+
strategy: str = "greedy",
|
| 294 |
+
n_samples: int = 100,
|
| 295 |
+
batch_size: int = 32,
|
| 296 |
+
) -> List[UncertaintySample]:
|
| 297 |
+
"""Sample using batch mode for efficiency.
|
| 298 |
+
|
| 299 |
+
Strategies:
|
| 300 |
+
- greedy: Select top n_samples by uncertainty
|
| 301 |
+
- diverse: Cluster-based diverse sampling
|
| 302 |
+
- random: Random baseline
|
| 303 |
+
"""
|
| 304 |
+
if strategy == "random":
|
| 305 |
+
import random
|
| 306 |
+
random.seed(42)
|
| 307 |
+
indices = random.sample(range(len(samples)), min(n_samples, len(samples)))
|
| 308 |
+
return [UncertaintySample(
|
| 309 |
+
sample_id=samples[i].get("id", f"sample_{i}"),
|
| 310 |
+
text=samples[i].get("text", ""),
|
| 311 |
+
uncertainty_score=0.0,
|
| 312 |
+
predicted_class="unknown",
|
| 313 |
+
predicted_prob=0.0,
|
| 314 |
+
) for i in indices]
|
| 315 |
+
|
| 316 |
+
elif strategy == "diverse":
|
| 317 |
+
return self.diversity_sample(
|
| 318 |
+
samples, tokenizer, n_samples, batch_size
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
else: # greedy
|
| 322 |
+
return self.select_samples(
|
| 323 |
+
samples, tokenizer, n_samples, batch_size
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def save_selected_samples(
|
| 328 |
+
samples: List[UncertaintySample],
|
| 329 |
+
output_path: str,
|
| 330 |
+
) -> None:
|
| 331 |
+
"""Save selected samples to JSON file."""
|
| 332 |
+
output_data = [s.to_dict() for s in samples]
|
| 333 |
+
|
| 334 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 335 |
+
json.dump(output_data, f, indent=2, ensure_ascii=False)
|
| 336 |
+
|
| 337 |
+
logger.info(f"Saved {len(samples)} samples to {output_path}")
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
if __name__ == "__main__":
|
| 341 |
+
print("UncertaintySampler module loaded")
|
| 342 |
+
print(f"Available methods: {[m.value for m in UncertaintyMethod]}")
|
annotation/automatic_verifier.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Automatic verification of annotations using rule-based checks."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import yaml
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class VerificationRule:
|
| 16 |
+
"""Base class for verification rules."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, rule_id: str, severity: str = "error"):
|
| 19 |
+
self.rule_id = rule_id
|
| 20 |
+
self.severity = severity # error, warning, info
|
| 21 |
+
|
| 22 |
+
def verify(self, sample: Dict) -> Tuple[bool, Optional[str]]:
|
| 23 |
+
"""Verify a sample. Returns (passed, error_message)."""
|
| 24 |
+
raise NotImplementedError
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TextLengthRule(VerificationRule):
|
| 28 |
+
"""Check if text length is within acceptable range."""
|
| 29 |
+
|
| 30 |
+
def __init__(self, min_length: int = 1, max_length: int = 500):
|
| 31 |
+
super().__init__("text_length")
|
| 32 |
+
self.min_length = min_length
|
| 33 |
+
self.max_length = max_length
|
| 34 |
+
|
| 35 |
+
def verify(self, sample: Dict) -> Tuple[bool, Optional[str]]:
|
| 36 |
+
text = sample.get("text", "")
|
| 37 |
+
length = len(text.strip())
|
| 38 |
+
|
| 39 |
+
if length < self.min_length:
|
| 40 |
+
return False, f"Text too short: {length} chars (min: {self.min_length})"
|
| 41 |
+
if length > self.max_length:
|
| 42 |
+
return False, f"Text too long: {length} chars (max: {self.max_length})"
|
| 43 |
+
|
| 44 |
+
return True, None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class SentimentConsistencyRule(VerificationRule):
|
| 48 |
+
"""Check sentiment consistency between text and prosody."""
|
| 49 |
+
|
| 50 |
+
# Sentiment keywords mapping
|
| 51 |
+
POSITIVE_KEYWORDS = ["ကျေးဇူး", "ပါး", "မင်္ဂလာ", "ဝမ်းသာ", "ပျော်"]
|
| 52 |
+
NEGATIVE_KEYWORDS = ["မကျေနပ်", "ဒေါသ", "စိတ်ဓာတ်ကျ", "ပူ", "ဆူ"]
|
| 53 |
+
|
| 54 |
+
def __init__(self):
|
| 55 |
+
super().__init__("sentiment_consistency")
|
| 56 |
+
|
| 57 |
+
def verify(self, sample: Dict) -> Tuple[bool, Optional[str]]:
|
| 58 |
+
text = sample.get("text", "")
|
| 59 |
+
prosody = sample.get("prosody", {})
|
| 60 |
+
|
| 61 |
+
text_positive = any(kw in text for kw in self.POSITIVE_KEYWORDS)
|
| 62 |
+
text_negative = any(kw in text for kw in self.NEGATIVE_KEYWORDS)
|
| 63 |
+
|
| 64 |
+
sentiment = sample.get("sentiment", "neutral")
|
| 65 |
+
|
| 66 |
+
# Warning only (not error) - prosody and text can diverge
|
| 67 |
+
if sentiment == "positive" and text_negative and not text_positive:
|
| 68 |
+
return True, "Warning: Text has negative keywords but labeled positive"
|
| 69 |
+
if sentiment == "negative" and text_positive and not text_negative:
|
| 70 |
+
return True, "Warning: Text has positive keywords but labeled negative"
|
| 71 |
+
|
| 72 |
+
return True, None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class LabelDistributionRule(VerificationRule):
|
| 76 |
+
"""Check if label distribution is reasonable."""
|
| 77 |
+
|
| 78 |
+
def __init__(
|
| 79 |
+
self,
|
| 80 |
+
min_samples_per_class: int = 10,
|
| 81 |
+
max_imbalance_ratio: float = 10.0,
|
| 82 |
+
):
|
| 83 |
+
super().__init__("label_distribution", severity="warning")
|
| 84 |
+
self.min_samples_per_class = min_samples_per_class
|
| 85 |
+
self.max_imbalance_ratio = max_imbalance_ratio
|
| 86 |
+
|
| 87 |
+
def verify(self, samples: List[Dict]) -> Tuple[bool, Optional[str]]:
|
| 88 |
+
if not samples:
|
| 89 |
+
return False, "No samples provided"
|
| 90 |
+
|
| 91 |
+
from collections import Counter
|
| 92 |
+
sentiments = [s.get("sentiment", "unknown") for s in samples]
|
| 93 |
+
counts = Counter(sentiments)
|
| 94 |
+
|
| 95 |
+
if len(counts) == 0:
|
| 96 |
+
return False, "No sentiment labels found"
|
| 97 |
+
|
| 98 |
+
# Check minimum samples per class
|
| 99 |
+
for sentiment, count in counts.items():
|
| 100 |
+
if count < self.min_samples_per_class:
|
| 101 |
+
return False, f"Class '{sentiment}' has only {count} samples (min: {self.min_samples_per_class})"
|
| 102 |
+
|
| 103 |
+
# Check imbalance
|
| 104 |
+
max_count = max(counts.values())
|
| 105 |
+
min_count = min(counts.values())
|
| 106 |
+
if max_count / min_count > self.max_imbalance_ratio:
|
| 107 |
+
return True, f"Warning: Imbalance ratio {max_count/min_count:.1f} > {self.max_imbalance_ratio}"
|
| 108 |
+
|
| 109 |
+
return True, None
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class DuplicateTextRule(VerificationRule):
|
| 113 |
+
"""Check for duplicate text entries."""
|
| 114 |
+
|
| 115 |
+
def __init__(self, threshold: float = 0.9):
|
| 116 |
+
super().__init__("duplicate_text", severity="warning")
|
| 117 |
+
self.threshold = threshold
|
| 118 |
+
|
| 119 |
+
def verify(self, samples: List[Dict]) -> Tuple[bool, Optional[str]]:
|
| 120 |
+
texts = [s.get("text", "").strip().lower() for s in samples]
|
| 121 |
+
|
| 122 |
+
duplicates = []
|
| 123 |
+
seen = {}
|
| 124 |
+
|
| 125 |
+
for i, text in enumerate(texts):
|
| 126 |
+
if text in seen:
|
| 127 |
+
duplicates.append((seen[text], i))
|
| 128 |
+
else:
|
| 129 |
+
seen[text] = i
|
| 130 |
+
|
| 131 |
+
if duplicates:
|
| 132 |
+
return True, f"Found {len(duplicates)} potential duplicates"
|
| 133 |
+
|
| 134 |
+
return True, None
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class AutomaticVerifier:
|
| 138 |
+
"""Verify annotations using rule-based checks."""
|
| 139 |
+
|
| 140 |
+
def __init__(self, rules_config: Optional[str] = None):
|
| 141 |
+
self.rules: List[VerificationRule] = []
|
| 142 |
+
|
| 143 |
+
if rules_config and Path(rules_config).exists():
|
| 144 |
+
self._load_config(rules_config)
|
| 145 |
+
else:
|
| 146 |
+
self._setup_default_rules()
|
| 147 |
+
|
| 148 |
+
def _setup_default_rules(self) -> None:
|
| 149 |
+
"""Set up default verification rules."""
|
| 150 |
+
self.rules = [
|
| 151 |
+
TextLengthRule(min_length=1, max_length=500),
|
| 152 |
+
SentimentConsistencyRule(),
|
| 153 |
+
LabelDistributionRule(),
|
| 154 |
+
DuplicateTextRule(),
|
| 155 |
+
]
|
| 156 |
+
|
| 157 |
+
def _load_config(self, config_path: str) -> None:
|
| 158 |
+
"""Load rules from config file."""
|
| 159 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 160 |
+
config = yaml.safe_load(f)
|
| 161 |
+
|
| 162 |
+
self.rules = []
|
| 163 |
+
|
| 164 |
+
for rule_def in config.get("rules", []):
|
| 165 |
+
rule_type = rule_def.get("type")
|
| 166 |
+
|
| 167 |
+
if rule_type == "text_length":
|
| 168 |
+
self.rules.append(TextLengthRule(
|
| 169 |
+
min_length=rule_def.get("min_length", 1),
|
| 170 |
+
max_length=rule_def.get("max_length", 500),
|
| 171 |
+
))
|
| 172 |
+
elif rule_type == "sentiment_consistency":
|
| 173 |
+
self.rules.append(SentimentConsistencyRule())
|
| 174 |
+
elif rule_type == "label_distribution":
|
| 175 |
+
self.rules.append(LabelDistributionRule(
|
| 176 |
+
min_samples_per_class=rule_def.get("min_samples_per_class", 10),
|
| 177 |
+
max_imbalance_ratio=rule_def.get("max_imbalance_ratio", 10.0),
|
| 178 |
+
))
|
| 179 |
+
elif rule_type == "duplicate_text":
|
| 180 |
+
self.rules.append(DuplicateTextRule(
|
| 181 |
+
threshold=rule_def.get("threshold", 0.9),
|
| 182 |
+
))
|
| 183 |
+
|
| 184 |
+
def verify_sample(self, sample: Dict) -> Dict[str, Any]:
|
| 185 |
+
"""Verify a single sample against all rules."""
|
| 186 |
+
results = {
|
| 187 |
+
"sample_id": sample.get("id", "unknown"),
|
| 188 |
+
"passed": True,
|
| 189 |
+
"errors": [],
|
| 190 |
+
"warnings": [],
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
for rule in self.rules:
|
| 194 |
+
if isinstance(rule, LabelDistributionRule) or isinstance(rule, DuplicateTextRule):
|
| 195 |
+
# These rules need full dataset
|
| 196 |
+
continue
|
| 197 |
+
|
| 198 |
+
passed, message = rule.verify(sample)
|
| 199 |
+
|
| 200 |
+
if not passed:
|
| 201 |
+
results["passed"] = False
|
| 202 |
+
if rule.severity == "error":
|
| 203 |
+
results["errors"].append({
|
| 204 |
+
"rule_id": rule.rule_id,
|
| 205 |
+
"message": message,
|
| 206 |
+
})
|
| 207 |
+
else:
|
| 208 |
+
results["warnings"].append({
|
| 209 |
+
"rule_id": rule.rule_id,
|
| 210 |
+
"message": message,
|
| 211 |
+
})
|
| 212 |
+
elif message:
|
| 213 |
+
results["warnings"].append({
|
| 214 |
+
"rule_id": rule.rule_id,
|
| 215 |
+
"message": message,
|
| 216 |
+
})
|
| 217 |
+
|
| 218 |
+
return results
|
| 219 |
+
|
| 220 |
+
def verify_dataset(
|
| 221 |
+
self,
|
| 222 |
+
samples: List[Dict],
|
| 223 |
+
) -> Dict[str, Any]:
|
| 224 |
+
"""Verify entire dataset."""
|
| 225 |
+
results = {
|
| 226 |
+
"total_samples": len(samples),
|
| 227 |
+
"sample_results": [],
|
| 228 |
+
"dataset_errors": [],
|
| 229 |
+
"statistics": {},
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
# Check sample-level rules
|
| 233 |
+
for sample in samples:
|
| 234 |
+
sample_result = self.verify_sample(sample)
|
| 235 |
+
results["sample_results"].append(sample_result)
|
| 236 |
+
|
| 237 |
+
# Check dataset-level rules
|
| 238 |
+
for rule in self.rules:
|
| 239 |
+
if isinstance(rule, (LabelDistributionRule, DuplicateTextRule)):
|
| 240 |
+
passed, message = rule.verify(samples)
|
| 241 |
+
if not passed:
|
| 242 |
+
results["dataset_errors"].append({
|
| 243 |
+
"rule_id": rule.rule_id,
|
| 244 |
+
"severity": rule.severity,
|
| 245 |
+
"message": message,
|
| 246 |
+
})
|
| 247 |
+
|
| 248 |
+
# Calculate statistics
|
| 249 |
+
total_errors = sum(
|
| 250 |
+
len(r.get("errors", []))
|
| 251 |
+
for r in results["sample_results"]
|
| 252 |
+
)
|
| 253 |
+
total_warnings = sum(
|
| 254 |
+
len(r.get("warnings", []))
|
| 255 |
+
for r in results["sample_results"]
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
results["statistics"] = {
|
| 259 |
+
"total_errors": total_errors,
|
| 260 |
+
"total_warnings": total_warnings,
|
| 261 |
+
"samples_passed": sum(
|
| 262 |
+
1 for r in results["sample_results"] if r["passed"]
|
| 263 |
+
),
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
return results
|
| 267 |
+
|
| 268 |
+
def filter_samples(
|
| 269 |
+
self,
|
| 270 |
+
samples: List[Dict],
|
| 271 |
+
remove_errors: bool = True,
|
| 272 |
+
remove_warnings: bool = False,
|
| 273 |
+
) -> Tuple[List[Dict], List[Dict]]:
|
| 274 |
+
"""Filter samples based on verification results."""
|
| 275 |
+
results = self.verify_dataset(samples)
|
| 276 |
+
|
| 277 |
+
kept = []
|
| 278 |
+
removed = []
|
| 279 |
+
|
| 280 |
+
for i, (sample, result) in enumerate(zip(samples, results["sample_results"])):
|
| 281 |
+
should_remove = False
|
| 282 |
+
|
| 283 |
+
if remove_errors and result["errors"]:
|
| 284 |
+
should_remove = True
|
| 285 |
+
if remove_warnings and result["warnings"]:
|
| 286 |
+
should_remove = True
|
| 287 |
+
|
| 288 |
+
if should_remove:
|
| 289 |
+
removed.append({
|
| 290 |
+
"sample": sample,
|
| 291 |
+
"reason": result,
|
| 292 |
+
})
|
| 293 |
+
else:
|
| 294 |
+
kept.append(sample)
|
| 295 |
+
|
| 296 |
+
logger.info(
|
| 297 |
+
f"Filtered: {len(kept)} kept, {len(removed)} removed"
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
return kept, removed
|
| 301 |
+
|
| 302 |
+
def generate_report(
|
| 303 |
+
self,
|
| 304 |
+
samples: List[Dict],
|
| 305 |
+
output_path: Optional[str] = None,
|
| 306 |
+
) -> str:
|
| 307 |
+
"""Generate verification report."""
|
| 308 |
+
results = self.verify_dataset(samples)
|
| 309 |
+
|
| 310 |
+
report_lines = [
|
| 311 |
+
"=" * 60,
|
| 312 |
+
"AUTOMATIC ANNOTATION VERIFICATION REPORT",
|
| 313 |
+
"=" * 60,
|
| 314 |
+
f"Total Samples: {results['total_samples']}",
|
| 315 |
+
f"Samples Passed: {results['statistics']['samples_passed']}",
|
| 316 |
+
f"Total Errors: {results['statistics']['total_errors']}",
|
| 317 |
+
f"Total Warnings: {results['statistics']['total_warnings']}",
|
| 318 |
+
"",
|
| 319 |
+
"-" * 60,
|
| 320 |
+
"DATASET-LEVEL ISSUES",
|
| 321 |
+
"-" * 60,
|
| 322 |
+
]
|
| 323 |
+
|
| 324 |
+
for error in results.get("dataset_errors", []):
|
| 325 |
+
report_lines.append(
|
| 326 |
+
f"[{error['severity'].upper()}] {error['rule_id']}: {error['message']}"
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
if not results.get("dataset_errors"):
|
| 330 |
+
report_lines.append("No dataset-level issues found.")
|
| 331 |
+
|
| 332 |
+
report_lines.extend([
|
| 333 |
+
"",
|
| 334 |
+
"-" * 60,
|
| 335 |
+
"SAMPLE-LEVEL ISSUES",
|
| 336 |
+
"-" * 60,
|
| 337 |
+
])
|
| 338 |
+
|
| 339 |
+
error_count = 0
|
| 340 |
+
for result in results["sample_results"]:
|
| 341 |
+
if result["errors"] or result["warnings"]:
|
| 342 |
+
error_count += 1
|
| 343 |
+
report_lines.append(f"\nSample: {result['sample_id']}")
|
| 344 |
+
for error in result["errors"]:
|
| 345 |
+
report_lines.append(f" ERROR: {error['message']}")
|
| 346 |
+
for warning in result["warnings"]:
|
| 347 |
+
report_lines.append(f" WARNING: {warning['message']}")
|
| 348 |
+
|
| 349 |
+
if error_count >= 20:
|
| 350 |
+
report_lines.append("\n... (showing first 20 samples with issues)")
|
| 351 |
+
break
|
| 352 |
+
|
| 353 |
+
report = "\n".join(report_lines)
|
| 354 |
+
|
| 355 |
+
if output_path:
|
| 356 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 357 |
+
f.write(report)
|
| 358 |
+
logger.info(f"Report saved to {output_path}")
|
| 359 |
+
|
| 360 |
+
return report
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def create_verifier(config_path: Optional[str] = None) -> AutomaticVerifier:
|
| 364 |
+
"""Factory function to create verifier."""
|
| 365 |
+
return AutomaticVerifier(rules_config=config_path)
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
if __name__ == "__main__":
|
| 369 |
+
verifier = create_verifier()
|
| 370 |
+
|
| 371 |
+
# Test samples
|
| 372 |
+
test_samples = [
|
| 373 |
+
{
|
| 374 |
+
"id": "utt_001",
|
| 375 |
+
"text": "ကျေးဇူးပါ",
|
| 376 |
+
"sentiment": "positive",
|
| 377 |
+
"prosody": {"mean_pitch": 150},
|
| 378 |
+
},
|
| 379 |
+
{
|
| 380 |
+
"id": "utt_002",
|
| 381 |
+
"text": "",
|
| 382 |
+
"sentiment": "negative",
|
| 383 |
+
},
|
| 384 |
+
]
|
| 385 |
+
|
| 386 |
+
results = verifier.verify_dataset(test_samples)
|
| 387 |
+
print(f"Verification results: {results['statistics']}")
|
annotation/labeler_tool.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GUI Tool for manual annotation of Myanmar speech data."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Callable, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class AnnotationLabel:
|
| 16 |
+
"""Single annotation label."""
|
| 17 |
+
utterance_id: str
|
| 18 |
+
text: str
|
| 19 |
+
sentiment: str
|
| 20 |
+
intensity: float # 0.0 - 1.0
|
| 21 |
+
confidence: float # 0.0 - 1.0
|
| 22 |
+
notes: str = ""
|
| 23 |
+
annotator: str = "anonymous"
|
| 24 |
+
timestamp: str = ""
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class AnnotationSession:
|
| 29 |
+
"""Annotation session data."""
|
| 30 |
+
session_id: str
|
| 31 |
+
samples: List[Dict]
|
| 32 |
+
labels: List[AnnotationLabel] = field(default_factory=list)
|
| 33 |
+
completed_indices: set = field(default_factory=set)
|
| 34 |
+
current_index: int = 0
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def progress(self) -> float:
|
| 38 |
+
if not self.samples:
|
| 39 |
+
return 0.0
|
| 40 |
+
return len(self.completed_indices) / len(self.samples)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class MyanmarAnnotationTool:
|
| 44 |
+
"""GUI-based annotation tool for Myanmar speech data."""
|
| 45 |
+
|
| 46 |
+
# Sentiment classes
|
| 47 |
+
SENTIMENT_CLASSES = [
|
| 48 |
+
("positive", "အပြုသဘော"),
|
| 49 |
+
("negative", "အနှုတ်သဘော"),
|
| 50 |
+
("neutral", "အလယ်အလတ်"),
|
| 51 |
+
("sarcastic", "သရော်သည်"),
|
| 52 |
+
("confused", "ရောထွေး"),
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
def __init__(self, output_dir: str = "data/annotations/human_annotated"):
|
| 56 |
+
self.output_dir = Path(output_dir)
|
| 57 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 58 |
+
self.session: Optional[AnnotationSession] = None
|
| 59 |
+
|
| 60 |
+
def load_dataset(self, path: str) -> AnnotationSession:
|
| 61 |
+
"""Load dataset for annotation."""
|
| 62 |
+
if path.endswith(".jsonl"):
|
| 63 |
+
samples = []
|
| 64 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 65 |
+
for line in f:
|
| 66 |
+
samples.append(json.loads(line))
|
| 67 |
+
elif path.endswith(".csv"):
|
| 68 |
+
df = pd.read_csv(path)
|
| 69 |
+
samples = df.to_dict("records")
|
| 70 |
+
else:
|
| 71 |
+
raise ValueError(f"Unsupported file format: {path}")
|
| 72 |
+
|
| 73 |
+
session_id = Path(path).stem
|
| 74 |
+
self.session = AnnotationSession(
|
| 75 |
+
session_id=session_id,
|
| 76 |
+
samples=samples,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
logger.info(f"Loaded {len(samples)} samples for annotation")
|
| 80 |
+
return self.session
|
| 81 |
+
|
| 82 |
+
def get_current_sample(self) -> Optional[Dict]:
|
| 83 |
+
"""Get current sample to annotate."""
|
| 84 |
+
if not self.session:
|
| 85 |
+
return None
|
| 86 |
+
if self.session.current_index >= len(self.session.samples):
|
| 87 |
+
return None
|
| 88 |
+
return self.session.samples[self.session.current_index]
|
| 89 |
+
|
| 90 |
+
def submit_annotation(
|
| 91 |
+
self,
|
| 92 |
+
sentiment: str,
|
| 93 |
+
intensity: float,
|
| 94 |
+
confidence: float,
|
| 95 |
+
notes: str = "",
|
| 96 |
+
annotator: str = "anonymous",
|
| 97 |
+
) -> bool:
|
| 98 |
+
"""Submit an annotation for the current sample."""
|
| 99 |
+
if not self.session:
|
| 100 |
+
logger.error("No active session")
|
| 101 |
+
return False
|
| 102 |
+
|
| 103 |
+
sample = self.get_current_sample()
|
| 104 |
+
if not sample:
|
| 105 |
+
logger.error("No current sample")
|
| 106 |
+
return False
|
| 107 |
+
|
| 108 |
+
from datetime import datetime
|
| 109 |
+
|
| 110 |
+
label = AnnotationLabel(
|
| 111 |
+
utterance_id=sample.get("id", f"utt_{self.session.current_index}"),
|
| 112 |
+
text=sample.get("text", ""),
|
| 113 |
+
sentiment=sentiment,
|
| 114 |
+
intensity=intensity,
|
| 115 |
+
confidence=confidence,
|
| 116 |
+
notes=notes,
|
| 117 |
+
annotator=annotator,
|
| 118 |
+
timestamp=datetime.now().isoformat(),
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
self.session.labels.append(label)
|
| 122 |
+
self.session.completed_indices.add(self.session.current_index)
|
| 123 |
+
|
| 124 |
+
# Move to next incomplete sample
|
| 125 |
+
self._advance_to_next()
|
| 126 |
+
|
| 127 |
+
logger.info(f"Annotated sample {label.utterance_id} as {sentiment}")
|
| 128 |
+
return True
|
| 129 |
+
|
| 130 |
+
def _advance_to_next(self) -> None:
|
| 131 |
+
"""Move to next incomplete sample."""
|
| 132 |
+
if not self.session:
|
| 133 |
+
return
|
| 134 |
+
|
| 135 |
+
for i in range(self.session.current_index + 1, len(self.session.samples)):
|
| 136 |
+
if i not in self.session.completed_indices:
|
| 137 |
+
self.session.current_index = i
|
| 138 |
+
return
|
| 139 |
+
|
| 140 |
+
for i in range(self.session.current_index):
|
| 141 |
+
if i not in self.session.completed_indices:
|
| 142 |
+
self.session.current_index = i
|
| 143 |
+
return
|
| 144 |
+
|
| 145 |
+
def skip_sample(self) -> bool:
|
| 146 |
+
"""Skip current sample without annotating."""
|
| 147 |
+
if not self.session:
|
| 148 |
+
return False
|
| 149 |
+
|
| 150 |
+
self._advance_to_next()
|
| 151 |
+
return True
|
| 152 |
+
|
| 153 |
+
def go_to_sample(self, index: int) -> bool:
|
| 154 |
+
"""Go to specific sample index."""
|
| 155 |
+
if not self.session:
|
| 156 |
+
return False
|
| 157 |
+
if 0 <= index < len(self.session.samples):
|
| 158 |
+
self.session.current_index = index
|
| 159 |
+
return True
|
| 160 |
+
return False
|
| 161 |
+
|
| 162 |
+
def save_session(self, path: Optional[str] = None) -> str:
|
| 163 |
+
"""Save annotation session to file."""
|
| 164 |
+
if not self.session:
|
| 165 |
+
raise ValueError("No active session")
|
| 166 |
+
|
| 167 |
+
if path is None:
|
| 168 |
+
path = self.output_dir / f"{self.session.session_id}_annotations.jsonl"
|
| 169 |
+
|
| 170 |
+
labels_data = [
|
| 171 |
+
{
|
| 172 |
+
"utterance_id": label.utterance_id,
|
| 173 |
+
"text": label.text,
|
| 174 |
+
"sentiment": label.sentiment,
|
| 175 |
+
"intensity": label.intensity,
|
| 176 |
+
"confidence": label.confidence,
|
| 177 |
+
"notes": label.notes,
|
| 178 |
+
"annotator": label.annotator,
|
| 179 |
+
"timestamp": label.timestamp,
|
| 180 |
+
}
|
| 181 |
+
for label in self.session.labels
|
| 182 |
+
]
|
| 183 |
+
|
| 184 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 185 |
+
for item in labels_data:
|
| 186 |
+
f.write(json.dumps(item, ensure_ascii=False) + "\n")
|
| 187 |
+
|
| 188 |
+
logger.info(f"Saved {len(labels_data)} annotations to {path}")
|
| 189 |
+
return str(path)
|
| 190 |
+
|
| 191 |
+
def export_to_dataframe(self) -> pd.DataFrame:
|
| 192 |
+
"""Export annotations as DataFrame."""
|
| 193 |
+
if not self.session:
|
| 194 |
+
raise ValueError("No active session")
|
| 195 |
+
|
| 196 |
+
return pd.DataFrame([
|
| 197 |
+
{
|
| 198 |
+
"utterance_id": label.utterance_id,
|
| 199 |
+
"text": label.text,
|
| 200 |
+
"sentiment": label.sentiment,
|
| 201 |
+
"intensity": label.intensity,
|
| 202 |
+
"confidence": label.confidence,
|
| 203 |
+
"notes": label.notes,
|
| 204 |
+
"annotator": label.annotator,
|
| 205 |
+
}
|
| 206 |
+
for label in self.session.labels
|
| 207 |
+
])
|
| 208 |
+
|
| 209 |
+
def get_statistics(self) -> Dict[str, Any]:
|
| 210 |
+
"""Get annotation statistics."""
|
| 211 |
+
if not self.session:
|
| 212 |
+
return {}
|
| 213 |
+
|
| 214 |
+
df = self.export_to_dataframe()
|
| 215 |
+
|
| 216 |
+
return {
|
| 217 |
+
"total_samples": len(self.session.samples),
|
| 218 |
+
"annotated": len(self.session.labels),
|
| 219 |
+
"remaining": len(self.session.samples) - len(self.session.labels),
|
| 220 |
+
"progress": self.session.progress,
|
| 221 |
+
"sentiment_distribution": df["sentiment"].value_counts().to_dict() if len(df) > 0 else {},
|
| 222 |
+
"annotator_counts": df["annotator"].value_counts().to_dict() if len(df) > 0 else {},
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# Gradio-based GUI
|
| 227 |
+
def create_gradio_interface(tool: MyanmarAnnotationTool):
|
| 228 |
+
"""Create Gradio-based GUI for annotation."""
|
| 229 |
+
import gradio as gr
|
| 230 |
+
|
| 231 |
+
with gr.Blocks(title="Myanmar Annotation Tool") as app:
|
| 232 |
+
gr.Markdown("# 🇲🇲 Myanmar Speech Annotation Tool")
|
| 233 |
+
|
| 234 |
+
with gr.Row():
|
| 235 |
+
with gr.Column():
|
| 236 |
+
sample_display = gr.Textbox(
|
| 237 |
+
label="Text to Annotate",
|
| 238 |
+
lines=3,
|
| 239 |
+
interactive=False,
|
| 240 |
+
)
|
| 241 |
+
prosody_display = gr.JSON(label="Prosody Features")
|
| 242 |
+
|
| 243 |
+
with gr.Column():
|
| 244 |
+
sentiment_dropdown = gr.Dropdown(
|
| 245 |
+
choices=[s[0] for s in tool.SENTIMENT_CLASSES],
|
| 246 |
+
label="Sentiment",
|
| 247 |
+
value="neutral",
|
| 248 |
+
)
|
| 249 |
+
intensity_slider = gr.Slider(
|
| 250 |
+
minimum=0.0,
|
| 251 |
+
maximum=1.0,
|
| 252 |
+
value=0.5,
|
| 253 |
+
step=0.1,
|
| 254 |
+
label="Intensity",
|
| 255 |
+
)
|
| 256 |
+
confidence_slider = gr.Slider(
|
| 257 |
+
minimum=0.0,
|
| 258 |
+
maximum=1.0,
|
| 259 |
+
value=0.8,
|
| 260 |
+
step=0.1,
|
| 261 |
+
label="Confidence",
|
| 262 |
+
)
|
| 263 |
+
notes_input = gr.Textbox(
|
| 264 |
+
label="Notes",
|
| 265 |
+
lines=2,
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
with gr.Row():
|
| 269 |
+
submit_btn = gr.Button("Submit", variant="primary")
|
| 270 |
+
skip_btn = gr.Button("Skip")
|
| 271 |
+
save_btn = gr.Button("Save Session")
|
| 272 |
+
|
| 273 |
+
with gr.Row():
|
| 274 |
+
progress_display = gr.Textbox(label="Progress", interactive=False)
|
| 275 |
+
stats_display = gr.JSON(label="Statistics")
|
| 276 |
+
|
| 277 |
+
def update_display():
|
| 278 |
+
sample = tool.get_current_sample()
|
| 279 |
+
if sample:
|
| 280 |
+
return (
|
| 281 |
+
sample.get("text", ""),
|
| 282 |
+
sample.get("prosody", {}),
|
| 283 |
+
)
|
| 284 |
+
return ("No more samples", {})
|
| 285 |
+
|
| 286 |
+
def submit_annotation(sentiment, intensity, confidence, notes):
|
| 287 |
+
tool.submit_annotation(sentiment, intensity, confidence, notes)
|
| 288 |
+
sample = tool.get_current_sample()
|
| 289 |
+
stats = tool.get_statistics()
|
| 290 |
+
if sample:
|
| 291 |
+
return (
|
| 292 |
+
sample.get("text", ""),
|
| 293 |
+
sample.get("prosody", {}),
|
| 294 |
+
f"{stats['annotated']}/{stats['total_samples']} ({stats['progress']*100:.1f}%)",
|
| 295 |
+
stats,
|
| 296 |
+
)
|
| 297 |
+
return ("All samples annotated!", {}, "100%", stats)
|
| 298 |
+
|
| 299 |
+
def skip():
|
| 300 |
+
tool.skip_sample()
|
| 301 |
+
return update_display()
|
| 302 |
+
|
| 303 |
+
def save():
|
| 304 |
+
path = tool.save_session()
|
| 305 |
+
return f"Saved to {path}"
|
| 306 |
+
|
| 307 |
+
submit_btn.click(
|
| 308 |
+
submit_annotation,
|
| 309 |
+
inputs=[sentiment_dropdown, intensity_slider, confidence_slider, notes_input],
|
| 310 |
+
outputs=[sample_display, prosody_display, progress_display, stats_display],
|
| 311 |
+
)
|
| 312 |
+
skip_btn.click(
|
| 313 |
+
skip,
|
| 314 |
+
outputs=[sample_display, prosody_display],
|
| 315 |
+
)
|
| 316 |
+
save_btn.click(
|
| 317 |
+
save,
|
| 318 |
+
outputs=[progress_display],
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
# Initialize display
|
| 322 |
+
app.load(
|
| 323 |
+
update_display,
|
| 324 |
+
outputs=[sample_display, prosody_display],
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
return app
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
if __name__ == "__main__":
|
| 331 |
+
tool = MyanmarAnnotationTool()
|
| 332 |
+
print("MyanmarAnnotationTool initialized")
|
| 333 |
+
print(f"Available sentiment classes: {[s[0] for s in tool.SENTIMENT_CLASSES]}")
|
augmentation/__init__.py
ADDED
|
File without changes
|
augmentation/back_translator.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Back-translation augmentation for Myanmar text.
|
| 2 |
+
|
| 3 |
+
Translates text to another language and back to create
|
| 4 |
+
paraphrased versions for data augmentation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Dict, List, Optional, Tuple
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class BackTranslator:
|
| 15 |
+
"""Back-translation augmentation using translation APIs."""
|
| 16 |
+
|
| 17 |
+
def __init__(
|
| 18 |
+
self,
|
| 19 |
+
translator_api: Optional[object] = None,
|
| 20 |
+
target_lang: str = "en",
|
| 21 |
+
source_lang: str = "my",
|
| 22 |
+
):
|
| 23 |
+
"""
|
| 24 |
+
Args:
|
| 25 |
+
translator_api: Translation API instance
|
| 26 |
+
target_lang: Target language for translation
|
| 27 |
+
source_lang: Source language
|
| 28 |
+
"""
|
| 29 |
+
self.translator_api = translator_api
|
| 30 |
+
self.target_lang = target_lang
|
| 31 |
+
self.source_lang = source_lang
|
| 32 |
+
|
| 33 |
+
def translate(
|
| 34 |
+
self,
|
| 35 |
+
text: str,
|
| 36 |
+
direction: str = "forward",
|
| 37 |
+
) -> Optional[str]:
|
| 38 |
+
"""Translate text.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
text: Text to translate
|
| 42 |
+
direction: "forward" (src->tgt) or "backward" (tgt->src)
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
Translated text or None if failed
|
| 46 |
+
"""
|
| 47 |
+
if self.translator_api is None:
|
| 48 |
+
# Simulate translation for testing
|
| 49 |
+
return self._simulate_translation(text, direction)
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
if direction == "forward":
|
| 53 |
+
return self.translator_api.translate(
|
| 54 |
+
text,
|
| 55 |
+
src=self.source_lang,
|
| 56 |
+
tgt=self.target_lang,
|
| 57 |
+
)
|
| 58 |
+
else:
|
| 59 |
+
return self.translator_api.translate(
|
| 60 |
+
text,
|
| 61 |
+
src=self.target_lang,
|
| 62 |
+
tgt=self.source_lang,
|
| 63 |
+
)
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error(f"Translation failed: {e}")
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
def _simulate_translation(
|
| 69 |
+
self,
|
| 70 |
+
text: str,
|
| 71 |
+
direction: str,
|
| 72 |
+
) -> str:
|
| 73 |
+
"""Simulate translation for testing without API.
|
| 74 |
+
|
| 75 |
+
In real use, this would call a translation service.
|
| 76 |
+
"""
|
| 77 |
+
# This is a placeholder - real implementation would use
|
| 78 |
+
# Google Translate, DeepL, or similar API
|
| 79 |
+
|
| 80 |
+
# For testing, just return the original text
|
| 81 |
+
# with a marker to indicate it was "translated"
|
| 82 |
+
marker = "[EN]" if direction == "forward" else "[MY]"
|
| 83 |
+
return f"{marker}{text}{marker}"
|
| 84 |
+
|
| 85 |
+
def back_translate(
|
| 86 |
+
self,
|
| 87 |
+
text: str,
|
| 88 |
+
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
| 89 |
+
"""Translate text to target language and back.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
text: Myanmar text
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
(forward_translation, back_translation, final_text)
|
| 96 |
+
"""
|
| 97 |
+
# Forward translation
|
| 98 |
+
forward = self.translate(text, "forward")
|
| 99 |
+
if forward is None:
|
| 100 |
+
return None, None, None
|
| 101 |
+
|
| 102 |
+
# Back translation
|
| 103 |
+
back = self.translate(forward, "backward")
|
| 104 |
+
if back is None:
|
| 105 |
+
return forward, None, None
|
| 106 |
+
|
| 107 |
+
return forward, back, back
|
| 108 |
+
|
| 109 |
+
def augment_dataset(
|
| 110 |
+
self,
|
| 111 |
+
samples: List[Dict],
|
| 112 |
+
batch_size: int = 10,
|
| 113 |
+
) -> List[Dict]:
|
| 114 |
+
"""Augment dataset using back-translation.
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
samples: List of sample dictionaries
|
| 118 |
+
batch_size: Batch size for API calls
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
List of augmented samples
|
| 122 |
+
"""
|
| 123 |
+
augmented = []
|
| 124 |
+
|
| 125 |
+
for i, sample in enumerate(samples):
|
| 126 |
+
text = sample.get("text", "")
|
| 127 |
+
|
| 128 |
+
forward, back, final = self.back_translate(text)
|
| 129 |
+
|
| 130 |
+
if final and final != text:
|
| 131 |
+
aug_sample = sample.copy()
|
| 132 |
+
aug_sample["text"] = final
|
| 133 |
+
aug_sample["forward_translation"] = forward
|
| 134 |
+
aug_sample["back_translation"] = back
|
| 135 |
+
aug_sample["augmentation_type"] = "back_translation"
|
| 136 |
+
aug_sample["is_augmented"] = True
|
| 137 |
+
augmented.append(aug_sample)
|
| 138 |
+
|
| 139 |
+
if (i + 1) % batch_size == 0:
|
| 140 |
+
logger.info(f"Processed {i + 1}/{len(samples)} samples")
|
| 141 |
+
|
| 142 |
+
return augmented
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class TranslationAugmenter:
|
| 146 |
+
"""Advanced translation-based augmentation."""
|
| 147 |
+
|
| 148 |
+
def __init__(
|
| 149 |
+
self,
|
| 150 |
+
translator_api: Optional[object] = None,
|
| 151 |
+
languages: Optional[List[str]] = None,
|
| 152 |
+
):
|
| 153 |
+
"""
|
| 154 |
+
Args:
|
| 155 |
+
translator_api: Translation API instance
|
| 156 |
+
languages: List of intermediate languages for multi-hop translation
|
| 157 |
+
"""
|
| 158 |
+
self.translator_api = translator_api
|
| 159 |
+
self.languages = languages or ["en", "zh", "ja", "ko"]
|
| 160 |
+
|
| 161 |
+
def multi_hop_translate(
|
| 162 |
+
self,
|
| 163 |
+
text: str,
|
| 164 |
+
intermediate_langs: Optional[List[str]] = None,
|
| 165 |
+
) -> str:
|
| 166 |
+
"""Translate through multiple intermediate languages.
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
text: Text to translate
|
| 170 |
+
intermediate_langs: Languages to translate through
|
| 171 |
+
|
| 172 |
+
Returns:
|
| 173 |
+
Final translated text
|
| 174 |
+
"""
|
| 175 |
+
if intermediate_langs is None:
|
| 176 |
+
intermediate_langs = random.sample(
|
| 177 |
+
self.languages,
|
| 178 |
+
k=min(2, len(self.languages))
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
current_text = text
|
| 182 |
+
|
| 183 |
+
for lang in intermediate_langs:
|
| 184 |
+
# Translate to intermediate language
|
| 185 |
+
if self.translator_api:
|
| 186 |
+
current_text = self.translator_api.translate(
|
| 187 |
+
current_text,
|
| 188 |
+
src="my",
|
| 189 |
+
tgt=lang,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# Translate back to Myanmar
|
| 193 |
+
if self.translator_api:
|
| 194 |
+
current_text = self.translator_api.translate(
|
| 195 |
+
current_text,
|
| 196 |
+
src=lang,
|
| 197 |
+
tgt="my",
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
return current_text
|
| 201 |
+
|
| 202 |
+
def paraphrase_with_context(
|
| 203 |
+
self,
|
| 204 |
+
text: str,
|
| 205 |
+
context: str,
|
| 206 |
+
) -> str:
|
| 207 |
+
"""Paraphrase text while maintaining context.
|
| 208 |
+
|
| 209 |
+
Args:
|
| 210 |
+
text: Text to paraphrase
|
| 211 |
+
context: Additional context to help translation
|
| 212 |
+
|
| 213 |
+
Returns:
|
| 214 |
+
Paraphrased text
|
| 215 |
+
"""
|
| 216 |
+
# Combine text with context
|
| 217 |
+
combined = f"{context}: {text}"
|
| 218 |
+
|
| 219 |
+
# Translate and back-translate
|
| 220 |
+
translator = BackTranslator(self.translator_api)
|
| 221 |
+
_, _, paraphrased = translator.back_translate(combined)
|
| 222 |
+
|
| 223 |
+
return paraphrased if paraphrased else text
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def create_back_translator(
|
| 227 |
+
translator_api: Optional[object] = None,
|
| 228 |
+
target_lang: str = "en",
|
| 229 |
+
) -> BackTranslator:
|
| 230 |
+
"""Factory function to create back translator."""
|
| 231 |
+
return BackTranslator(
|
| 232 |
+
translator_api=translator_api,
|
| 233 |
+
target_lang=target_lang,
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
if __name__ == "__main__":
|
| 238 |
+
print("BackTranslator loaded")
|
| 239 |
+
print("For production use, integrate with translation APIs like:")
|
| 240 |
+
print(" - Google Cloud Translation")
|
| 241 |
+
print(" - DeepL API")
|
| 242 |
+
print(" - transformers.TranslationPipeline")
|
augmentation/perturbator.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Text perturbation for adversarial data augmentation.
|
| 2 |
+
|
| 3 |
+
Applies various perturbations to text to create challenging
|
| 4 |
+
training examples that improve model robustness.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import random
|
| 8 |
+
import re
|
| 9 |
+
from enum import Enum
|
| 10 |
+
from typing import Callable, Dict, List, Optional, Tuple
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class PerturbationType(str, Enum):
|
| 14 |
+
"""Types of text perturbations."""
|
| 15 |
+
CHAR_SWAP = "char_swap"
|
| 16 |
+
CHAR_DELETE = "char_delete"
|
| 17 |
+
CHAR_DUPLICATE = "char_duplicate"
|
| 18 |
+
WORD_SWAP = "word_swap"
|
| 19 |
+
WORD_DELETE = "word_delete"
|
| 20 |
+
WORD_DUPLICATE = "word_duplicate"
|
| 21 |
+
SENTENCE_SHUFFLE = "sentence_shuffle"
|
| 22 |
+
KEYBOARD_typo = "keyboard_typo"
|
| 23 |
+
RANDOM_CASE = "random_case"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TextPerturbator:
|
| 27 |
+
"""Apply perturbations to Myanmar text."""
|
| 28 |
+
|
| 29 |
+
# Myanmar keyboard layout (simplified)
|
| 30 |
+
KEYBOARD_LAYOUT = {
|
| 31 |
+
"က": ["ခ", "ဂ"],
|
| 32 |
+
"ခ": ["က", "ဂ", "ဃ"],
|
| 33 |
+
"ဂ": ["က", "ခ"],
|
| 34 |
+
"ဃ": ["ခ"],
|
| 35 |
+
"င": ["စ", "ဆ"],
|
| 36 |
+
"စ": ["က", "င", "ဆ", "ဇ"],
|
| 37 |
+
"ဆ": ["င", "စ", "ဇ"],
|
| 38 |
+
"ဇ": ["စ", "ဆ", "ဈ"],
|
| 39 |
+
"ဈ": ["ဇ"],
|
| 40 |
+
"ဉ": ["ည"],
|
| 41 |
+
"ည": ["ဉ", "ဋ"],
|
| 42 |
+
"ဋ": ["ည", "ဌ"],
|
| 43 |
+
"ဌ": ["ဋ", "ဍ"],
|
| 44 |
+
"ဍ": ["ဌ", "ဎ"],
|
| 45 |
+
"ဎ": ["ဍ", "ဏ"],
|
| 46 |
+
"ဏ": ["ဎ", "တ"],
|
| 47 |
+
"တ": ["ဏ", "ထ", "ဒ"],
|
| 48 |
+
"ထ": ["တ", "ဓ"],
|
| 49 |
+
"ဓ": ["ထ", "ဒ"],
|
| 50 |
+
"ဒ": ["တ", "ဓ", "န"],
|
| 51 |
+
"န": ["ဒ", "ပ", "ဖ"],
|
| 52 |
+
"ပ": ["န", "ဖ", "ဗ"],
|
| 53 |
+
"ဖ": ["န", "ပ", "ဗ"],
|
| 54 |
+
"ဗ": ["ပ", "ဖ"],
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
def __init__(self, seed: int = 42):
|
| 58 |
+
random.seed(seed)
|
| 59 |
+
self.perturbation_count = 0
|
| 60 |
+
|
| 61 |
+
def char_swap(self, text: str, prob: float = 0.1) -> str:
|
| 62 |
+
"""Swap adjacent characters."""
|
| 63 |
+
chars = list(text)
|
| 64 |
+
for i in range(len(chars) - 1):
|
| 65 |
+
if random.random() < prob:
|
| 66 |
+
chars[i], chars[i + 1] = chars[i + 1], chars[i]
|
| 67 |
+
return "".join(chars)
|
| 68 |
+
|
| 69 |
+
def char_delete(self, text: str, prob: float = 0.05) -> str:
|
| 70 |
+
"""Delete random characters."""
|
| 71 |
+
chars = list(text)
|
| 72 |
+
result = [c for c in chars if random.random() > prob]
|
| 73 |
+
return "".join(result) if result else text
|
| 74 |
+
|
| 75 |
+
def char_duplicate(self, text: str, prob: float = 0.05) -> str:
|
| 76 |
+
"""Duplicate random characters."""
|
| 77 |
+
chars = list(text)
|
| 78 |
+
result = []
|
| 79 |
+
for c in chars:
|
| 80 |
+
result.append(c)
|
| 81 |
+
if random.random() < prob:
|
| 82 |
+
result.append(c)
|
| 83 |
+
return "".join(result)
|
| 84 |
+
|
| 85 |
+
def word_swap(self, text: str, prob: float = 0.1) -> str:
|
| 86 |
+
"""Swap adjacent words."""
|
| 87 |
+
words = text.split()
|
| 88 |
+
if len(words) < 2:
|
| 89 |
+
return text
|
| 90 |
+
|
| 91 |
+
for i in range(len(words) - 1):
|
| 92 |
+
if random.random() < prob:
|
| 93 |
+
words[i], words[i + 1] = words[i + 1], words[i]
|
| 94 |
+
|
| 95 |
+
return " ".join(words)
|
| 96 |
+
|
| 97 |
+
def word_delete(self, text: str, prob: float = 0.1) -> str:
|
| 98 |
+
"""Delete random words."""
|
| 99 |
+
words = text.split()
|
| 100 |
+
if len(words) < 2:
|
| 101 |
+
return text
|
| 102 |
+
|
| 103 |
+
result = [w for w in words if random.random() > prob]
|
| 104 |
+
return " ".join(result) if result else text
|
| 105 |
+
|
| 106 |
+
def word_duplicate(self, text: str, prob: float = 0.1) -> str:
|
| 107 |
+
"""Duplicate random words."""
|
| 108 |
+
words = text.split()
|
| 109 |
+
result = []
|
| 110 |
+
for w in words:
|
| 111 |
+
result.append(w)
|
| 112 |
+
if random.random() < prob:
|
| 113 |
+
result.append(w)
|
| 114 |
+
return " ".join(result)
|
| 115 |
+
|
| 116 |
+
def keyboard_typo(self, text: str, prob: float = 0.1) -> str:
|
| 117 |
+
"""Introduce keyboard typos."""
|
| 118 |
+
chars = list(text)
|
| 119 |
+
result = []
|
| 120 |
+
|
| 121 |
+
for c in chars:
|
| 122 |
+
if random.random() < prob and c in self.KEYBOARD_LAYOUT:
|
| 123 |
+
# Replace with keyboard neighbor
|
| 124 |
+
neighbor = random.choice(self.KEYBOARD_LAYOUT[c])
|
| 125 |
+
result.append(neighbor)
|
| 126 |
+
else:
|
| 127 |
+
result.append(c)
|
| 128 |
+
|
| 129 |
+
return "".join(result)
|
| 130 |
+
|
| 131 |
+
def random_case(self, text: str, prob: float = 0.1) -> str:
|
| 132 |
+
"""Randomly change case of characters (for mixed scripts)."""
|
| 133 |
+
# Myanmar doesn't have case, but this can affect punctuation
|
| 134 |
+
chars = list(text)
|
| 135 |
+
for i in range(len(chars)):
|
| 136 |
+
if chars[i].isupper() and random.random() < prob:
|
| 137 |
+
chars[i] = chars[i].lower()
|
| 138 |
+
elif chars[i].islower() and random.random() < prob:
|
| 139 |
+
chars[i] = chars[i].upper()
|
| 140 |
+
return "".join(chars)
|
| 141 |
+
|
| 142 |
+
def sentence_shuffle(self, text: str) -> str:
|
| 143 |
+
"""Shuffle sentences in multi-sentence text."""
|
| 144 |
+
sentences = re.split(r'[။၊।\.\!\?]+', text)
|
| 145 |
+
sentences = [s.strip() for s in sentences if s.strip()]
|
| 146 |
+
|
| 147 |
+
if len(sentences) < 2:
|
| 148 |
+
return text
|
| 149 |
+
|
| 150 |
+
random.shuffle(sentences)
|
| 151 |
+
return " ".join(sentences)
|
| 152 |
+
|
| 153 |
+
def apply_perturbation(
|
| 154 |
+
self,
|
| 155 |
+
text: str,
|
| 156 |
+
perturbation_type: PerturbationType,
|
| 157 |
+
prob: float = 0.1,
|
| 158 |
+
) -> str:
|
| 159 |
+
"""Apply a specific perturbation.
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
text: Myanmar text
|
| 163 |
+
perturbation_type: Type of perturbation
|
| 164 |
+
prob: Probability of perturbation
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
Perturbed text
|
| 168 |
+
"""
|
| 169 |
+
if perturbation_type == PerturbationType.CHAR_SWAP:
|
| 170 |
+
return self.char_swap(text, prob)
|
| 171 |
+
elif perturbation_type == PerturbationType.CHAR_DELETE:
|
| 172 |
+
return self.char_delete(text, prob)
|
| 173 |
+
elif perturbation_type == PerturbationType.CHAR_DUPLICATE:
|
| 174 |
+
return self.char_duplicate(text, prob)
|
| 175 |
+
elif perturbation_type == PerturbationType.WORD_SWAP:
|
| 176 |
+
return self.word_swap(text, prob)
|
| 177 |
+
elif perturbation_type == PerturbationType.WORD_DELETE:
|
| 178 |
+
return self.word_delete(text, prob)
|
| 179 |
+
elif perturbation_type == PerturbationType.WORD_DUPLICATE:
|
| 180 |
+
return self.word_duplicate(text, prob)
|
| 181 |
+
elif perturbation_type == PerturbationType.KEYBOARD_typo:
|
| 182 |
+
return self.keyboard_typo(text, prob)
|
| 183 |
+
elif perturbation_type == PerturbationType.RANDOM_CASE:
|
| 184 |
+
return self.random_case(text, prob)
|
| 185 |
+
elif perturbation_type == PerturbationType.SENTENCE_SHUFFLE:
|
| 186 |
+
return self.sentence_shuffle(text)
|
| 187 |
+
else:
|
| 188 |
+
return text
|
| 189 |
+
|
| 190 |
+
def apply_random_perturbations(
|
| 191 |
+
self,
|
| 192 |
+
text: str,
|
| 193 |
+
n_perturbations: int = 2,
|
| 194 |
+
prob: float = 0.1,
|
| 195 |
+
) -> Tuple[str, List[PerturbationType]]:
|
| 196 |
+
"""Apply random perturbations.
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
text: Myanmar text
|
| 200 |
+
n_perturbations: Number of perturbations to apply
|
| 201 |
+
prob: Probability for each perturbation
|
| 202 |
+
|
| 203 |
+
Returns:
|
| 204 |
+
(perturbed_text, list_of_applied_perturbations)
|
| 205 |
+
"""
|
| 206 |
+
perturbations = list(PerturbationType)
|
| 207 |
+
applied = []
|
| 208 |
+
|
| 209 |
+
current_text = text
|
| 210 |
+
|
| 211 |
+
for _ in range(n_perturbations):
|
| 212 |
+
pert_type = random.choice(perturbations)
|
| 213 |
+
current_text = self.apply_perturbation(current_text, pert_type, prob)
|
| 214 |
+
applied.append(pert_type)
|
| 215 |
+
|
| 216 |
+
self.perturbation_count += 1
|
| 217 |
+
|
| 218 |
+
return current_text, applied
|
| 219 |
+
|
| 220 |
+
def augment_dataset(
|
| 221 |
+
self,
|
| 222 |
+
samples: List[Dict],
|
| 223 |
+
n_perturbations: int = 2,
|
| 224 |
+
prob: float = 0.1,
|
| 225 |
+
n_augmentations: int = 2,
|
| 226 |
+
) -> List[Dict]:
|
| 227 |
+
"""Augment dataset with perturbations.
|
| 228 |
+
|
| 229 |
+
Args:
|
| 230 |
+
samples: List of sample dictionaries
|
| 231 |
+
n_perturbations: Number of perturbations per augmentation
|
| 232 |
+
prob: Probability for each perturbation
|
| 233 |
+
n_augmentations: Number of augmentations per sample
|
| 234 |
+
|
| 235 |
+
Returns:
|
| 236 |
+
List of augmented samples
|
| 237 |
+
"""
|
| 238 |
+
augmented = []
|
| 239 |
+
|
| 240 |
+
for sample in samples:
|
| 241 |
+
text = sample.get("text", "")
|
| 242 |
+
|
| 243 |
+
for i in range(n_augmentations):
|
| 244 |
+
aug_text, applied = self.apply_random_perturbations(
|
| 245 |
+
text,
|
| 246 |
+
n_perturbations=n_perturbations,
|
| 247 |
+
prob=prob,
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
aug_sample = sample.copy()
|
| 251 |
+
aug_sample["text"] = aug_text
|
| 252 |
+
aug_sample["augmentation_id"] = i
|
| 253 |
+
aug_sample["perturbations"] = [p.value for p in applied]
|
| 254 |
+
aug_sample["is_augmented"] = True
|
| 255 |
+
augmented.append(aug_sample)
|
| 256 |
+
|
| 257 |
+
return augmented
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
class AdversarialPerturbator:
|
| 261 |
+
"""Advanced adversarial perturbations targeting specific weaknesses."""
|
| 262 |
+
|
| 263 |
+
def __init__(self):
|
| 264 |
+
self.base_perturbator = TextPerturbator()
|
| 265 |
+
|
| 266 |
+
def confuse_sentiment_keywords(
|
| 267 |
+
self,
|
| 268 |
+
text: str,
|
| 269 |
+
keyword_replacements: Dict[str, str],
|
| 270 |
+
) -> str:
|
| 271 |
+
"""Replace sentiment keywords to flip or confuse sentiment.
|
| 272 |
+
|
| 273 |
+
Args:
|
| 274 |
+
text: Myanmar text
|
| 275 |
+
keyword_replacements: Dict of keyword -> replacement
|
| 276 |
+
|
| 277 |
+
Returns:
|
| 278 |
+
Text with keywords replaced
|
| 279 |
+
"""
|
| 280 |
+
for keyword, replacement in keyword_replacements.items():
|
| 281 |
+
if keyword in text:
|
| 282 |
+
text = text.replace(keyword, replacement, 1) # Replace first occurrence only
|
| 283 |
+
return text
|
| 284 |
+
|
| 285 |
+
def add_distractors(
|
| 286 |
+
self,
|
| 287 |
+
text: str,
|
| 288 |
+
distractors: List[str] = None,
|
| 289 |
+
) -> str:
|
| 290 |
+
"""Add distractor phrases to text.
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
text: Myanmar text
|
| 294 |
+
distractors: List of distractor phrases
|
| 295 |
+
|
| 296 |
+
Returns:
|
| 297 |
+
Text with distractors added
|
| 298 |
+
"""
|
| 299 |
+
if distractors is None:
|
| 300 |
+
distractors = [
|
| 301 |
+
"အဲ့ဒါကို",
|
| 302 |
+
"ဟုတ်ကဲ့",
|
| 303 |
+
"နောက်တော့",
|
| 304 |
+
]
|
| 305 |
+
|
| 306 |
+
distractor = random.choice(distractors)
|
| 307 |
+
words = text.split()
|
| 308 |
+
|
| 309 |
+
if len(words) >= 3:
|
| 310 |
+
insert_pos = random.randint(1, len(words) - 1)
|
| 311 |
+
words.insert(insert_pos, distractor)
|
| 312 |
+
|
| 313 |
+
return " ".join(words)
|
| 314 |
+
|
| 315 |
+
def paraphrase_style(self, text: str, style: str = "formal") -> str:
|
| 316 |
+
"""Change text style (formal/informal).
|
| 317 |
+
|
| 318 |
+
Args:
|
| 319 |
+
text: Myanmar text
|
| 320 |
+
style: Target style ("formal" or "informal")
|
| 321 |
+
|
| 322 |
+
Returns:
|
| 323 |
+
Text with changed style
|
| 324 |
+
"""
|
| 325 |
+
style_markers = {
|
| 326 |
+
"formal": {
|
| 327 |
+
"add": ["သည်", "မှာ", "ကို", "ဖြင့်"],
|
| 328 |
+
"remove": ["နော်", "ဟုတ်"],
|
| 329 |
+
},
|
| 330 |
+
"informal": {
|
| 331 |
+
"add": ["နော်", "ဟုတ်"],
|
| 332 |
+
"remove": ["သည်", "မှာ", "ကို", "ဖြင့်"],
|
| 333 |
+
},
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
markers = style_markers.get(style, style_markers["formal"])
|
| 337 |
+
|
| 338 |
+
for marker in markers.get("add", []):
|
| 339 |
+
if marker not in text and random.random() < 0.3:
|
| 340 |
+
words = text.split()
|
| 341 |
+
insert_pos = random.randint(0, len(words))
|
| 342 |
+
words.insert(insert_pos, marker)
|
| 343 |
+
text = " ".join(words)
|
| 344 |
+
|
| 345 |
+
for marker in markers.get("remove", []):
|
| 346 |
+
if marker in text and random.random() < 0.5:
|
| 347 |
+
text = text.replace(marker, "")
|
| 348 |
+
|
| 349 |
+
return text
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def create_perturbator(seed: int = 42) -> TextPerturbator:
|
| 353 |
+
"""Factory function to create perturbator."""
|
| 354 |
+
return TextPerturbator(seed=seed)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
if __name__ == "__main__":
|
| 358 |
+
perturbator = create_perturbator()
|
| 359 |
+
|
| 360 |
+
test_text = "ကျေးဇူးပါ မင်္ဂလာပါ"
|
| 361 |
+
|
| 362 |
+
print(f"Original: {test_text}")
|
| 363 |
+
print(f"\nRandom perturbations:")
|
| 364 |
+
|
| 365 |
+
for i in range(3):
|
| 366 |
+
aug, applied = perturbator.apply_random_perturbations(test_text)
|
| 367 |
+
print(f" {i+1}. {aug}")
|
| 368 |
+
print(f" Applied: {[p.value for p in applied]}")
|
augmentation/synonym_replacer.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synonym replacement for adversarial data augmentation.
|
| 2 |
+
|
| 3 |
+
Replaces words with synonyms to create challenging training examples
|
| 4 |
+
that help improve model robustness.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import random
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Dict, List, Optional, Set, Tuple
|
| 10 |
+
|
| 11 |
+
import yaml
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class MyanmarSynonymReplacer:
|
| 15 |
+
"""Replace words with Myanmar synonyms for data augmentation."""
|
| 16 |
+
|
| 17 |
+
# Basic synonym dictionary for Myanmar
|
| 18 |
+
SYNONYMS = {
|
| 19 |
+
"ကျေးဇူး": ["ခန့်ညား", "ဂုဏ်ပြု", "အားထုတ်"],
|
| 20 |
+
"ပါး": ["များ", "အရမ်း", "အလွန်"],
|
| 21 |
+
"သိပ်": ["အရမ်း", "များစွာ", "ပါး"],
|
| 22 |
+
"ကောင်း": ["မွန်", "သန့်", "စင်"],
|
| 23 |
+
"ဆိုး": ["မကောင်း", "ယုတ်", "ညံ့"],
|
| 24 |
+
"ပျော်": ["ရွှင်", "ပီတိ", "မင်္ဂလာ"],
|
| 25 |
+
"စိတ်မကောင်း": ["စိတ်ဓာတ်ကျ", "ဝမ်းနည်း", "ဒေါသ"],
|
| 26 |
+
"လာ": ["ရောက်", "သွား", "ပို့"],
|
| 27 |
+
"သွား": ["သွား", "ထွက်", "မောင်း"],
|
| 28 |
+
"ပေး": ["ပေးဆောင်", "မွေးစား", "အပ်"],
|
| 29 |
+
"ယူ": ["ရ", "လက်ခံ", "ရှာ"],
|
| 30 |
+
"မှန်": ["ဟုတ်", "ကျိ", "သင့်"],
|
| 31 |
+
"မှား": ["မဟုတ်", "အမှား", "ယွင်း"],
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
synonym_file: Optional[str] = None,
|
| 37 |
+
seed: int = 42,
|
| 38 |
+
):
|
| 39 |
+
"""
|
| 40 |
+
Args:
|
| 41 |
+
synonym_file: Path to YAML file with custom synonyms
|
| 42 |
+
seed: Random seed for reproducibility
|
| 43 |
+
"""
|
| 44 |
+
random.seed(seed)
|
| 45 |
+
|
| 46 |
+
self.synonyms = dict(self.SYNONYMS)
|
| 47 |
+
|
| 48 |
+
if synonym_file and Path(synonym_file).exists():
|
| 49 |
+
self._load_synonyms(synonym_file)
|
| 50 |
+
|
| 51 |
+
def _load_synonyms(self, path: str) -> None:
|
| 52 |
+
"""Load synonyms from YAML file."""
|
| 53 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 54 |
+
custom = yaml.safe_load(f)
|
| 55 |
+
|
| 56 |
+
if custom:
|
| 57 |
+
self.synonyms.update(custom)
|
| 58 |
+
|
| 59 |
+
def get_synonyms(self, word: str) -> List[str]:
|
| 60 |
+
"""Get synonyms for a word."""
|
| 61 |
+
return self.synonyms.get(word, [])
|
| 62 |
+
|
| 63 |
+
def replace_word(self, word: str) -> Tuple[str, bool]:
|
| 64 |
+
"""Replace a word with a synonym.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
word: Word to replace
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
(replaced_word, was_replaced)
|
| 71 |
+
"""
|
| 72 |
+
synonyms = self.get_synonyms(word)
|
| 73 |
+
|
| 74 |
+
if synonyms:
|
| 75 |
+
replacement = random.choice(synonyms)
|
| 76 |
+
return replacement, True
|
| 77 |
+
|
| 78 |
+
return word, False
|
| 79 |
+
|
| 80 |
+
def augment_text(
|
| 81 |
+
self,
|
| 82 |
+
text: str,
|
| 83 |
+
replace_prob: float = 0.3,
|
| 84 |
+
max_replacements: int = 3,
|
| 85 |
+
) -> Tuple[str, List[Tuple[str, str]]]:
|
| 86 |
+
"""Augment text by replacing words with synonyms.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
text: Myanmar text
|
| 90 |
+
replace_prob: Probability of replacing each synonym word
|
| 91 |
+
max_replacements: Maximum number of replacements
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
(augmented_text, list_of_replacements)
|
| 95 |
+
"""
|
| 96 |
+
words = text.split()
|
| 97 |
+
replacements = []
|
| 98 |
+
augmented_words = []
|
| 99 |
+
|
| 100 |
+
num_replaced = 0
|
| 101 |
+
|
| 102 |
+
for word in words:
|
| 103 |
+
if num_replaced >= max_replacements:
|
| 104 |
+
augmented_words.append(word)
|
| 105 |
+
continue
|
| 106 |
+
|
| 107 |
+
if word in self.synonyms and random.random() < replace_prob:
|
| 108 |
+
new_word, replaced = self.replace_word(word)
|
| 109 |
+
if replaced:
|
| 110 |
+
augmented_words.append(new_word)
|
| 111 |
+
replacements.append((word, new_word))
|
| 112 |
+
num_replaced += 1
|
| 113 |
+
else:
|
| 114 |
+
augmented_words.append(word)
|
| 115 |
+
else:
|
| 116 |
+
augmented_words.append(word)
|
| 117 |
+
|
| 118 |
+
return " ".join(augmented_words), replacements
|
| 119 |
+
|
| 120 |
+
def augment_dataset(
|
| 121 |
+
self,
|
| 122 |
+
samples: List[Dict],
|
| 123 |
+
replace_prob: float = 0.3,
|
| 124 |
+
max_replacements: int = 3,
|
| 125 |
+
n_augmentations: int = 2,
|
| 126 |
+
) -> List[Dict]:
|
| 127 |
+
"""Augment entire dataset.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
samples: List of sample dictionaries
|
| 131 |
+
replace_prob: Probability of replacing each word
|
| 132 |
+
max_replacements: Maximum replacements per text
|
| 133 |
+
n_augmentations: Number of augmentations per sample
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
List of augmented samples
|
| 137 |
+
"""
|
| 138 |
+
augmented = []
|
| 139 |
+
|
| 140 |
+
for sample in samples:
|
| 141 |
+
text = sample.get("text", "")
|
| 142 |
+
|
| 143 |
+
for i in range(n_augmentations):
|
| 144 |
+
aug_text, replacements = self.augment_text(
|
| 145 |
+
text,
|
| 146 |
+
replace_prob=replace_prob,
|
| 147 |
+
max_replacements=max_replacements,
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
if replacements: # Only add if something was replaced
|
| 151 |
+
aug_sample = sample.copy()
|
| 152 |
+
aug_sample["text"] = aug_text
|
| 153 |
+
aug_sample["augmentation_id"] = i
|
| 154 |
+
aug_sample["replacements"] = replacements
|
| 155 |
+
aug_sample["is_augmented"] = True
|
| 156 |
+
augmented.append(aug_sample)
|
| 157 |
+
|
| 158 |
+
return augmented
|
| 159 |
+
|
| 160 |
+
def get_replacement_stats(self) -> Dict:
|
| 161 |
+
"""Get statistics about synonym coverage."""
|
| 162 |
+
total_words = sum(len(syns) for syns in self.synonyms.values())
|
| 163 |
+
return {
|
| 164 |
+
"num_synonym_groups": len(self.synonyms),
|
| 165 |
+
"total_synonyms": total_words,
|
| 166 |
+
"avg_synonyms_per_word": total_words / len(self.synonyms) if self.synonyms else 0,
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class ContextualSynonymReplacer:
|
| 171 |
+
"""Synonym replacer that considers context."""
|
| 172 |
+
|
| 173 |
+
def __init__(self):
|
| 174 |
+
# Context-dependent synonyms
|
| 175 |
+
self.CONTEXT_SYNONYMS = {
|
| 176 |
+
"formal": {
|
| 177 |
+
"ကျေးဇူး": ["ဂုဏ်ပြုမှတ်ရှိပါ", "အထူးပင်ကျေးဇူးတင်ပါ"],
|
| 178 |
+
"သိပ်": ["အလွန်", "အထူးသဖြင့်"],
|
| 179 |
+
},
|
| 180 |
+
"informal": {
|
| 181 |
+
"ကျေးဇူး": ["ခန့်ညား", "ကျေးဇူးလည်းပါ"],
|
| 182 |
+
"ပါး": ["ပို", "အရမ်း"],
|
| 183 |
+
},
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
def augment_by_context(
|
| 187 |
+
self,
|
| 188 |
+
text: str,
|
| 189 |
+
context: str = "formal",
|
| 190 |
+
) -> str:
|
| 191 |
+
"""Augment text using context-specific synonyms.
|
| 192 |
+
|
| 193 |
+
Args:
|
| 194 |
+
text: Myanmar text
|
| 195 |
+
context: Context type ("formal" or "informal")
|
| 196 |
+
|
| 197 |
+
Returns:
|
| 198 |
+
Augmented text
|
| 199 |
+
"""
|
| 200 |
+
context_syns = self.CONTEXT_SYNONYMS.get(context, {})
|
| 201 |
+
|
| 202 |
+
for word, synonyms in context_syns.items():
|
| 203 |
+
if word in text:
|
| 204 |
+
replacement = random.choice(synonyms)
|
| 205 |
+
text = text.replace(word, replacement, 1)
|
| 206 |
+
|
| 207 |
+
return text
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def create_synonym_replacer(
|
| 211 |
+
synonym_file: Optional[str] = None,
|
| 212 |
+
) -> MyanmarSynonymReplacer:
|
| 213 |
+
"""Factory function to create synonym replacer."""
|
| 214 |
+
return MyanmarSynonymReplacer(synonym_file=synonym_file)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
if __name__ == "__main__":
|
| 218 |
+
replacer = create_synonym_replacer()
|
| 219 |
+
|
| 220 |
+
test_text = "ကျေးဇူးပါးသိပ်ကောင်းတယ်"
|
| 221 |
+
|
| 222 |
+
for i in range(3):
|
| 223 |
+
aug, replacements = replacer.augment_text(test_text)
|
| 224 |
+
print(f"Original: {test_text}")
|
| 225 |
+
print(f"Augmented: {aug}")
|
| 226 |
+
print(f"Replacements: {replacements}")
|
| 227 |
+
print()
|
data_processing/__init__.py
ADDED
|
File without changes
|
data_processing/audio_processor.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio processing module for Myanmar Ghost project."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Optional, Tuple
|
| 6 |
+
|
| 7 |
+
import librosa
|
| 8 |
+
import numpy as np
|
| 9 |
+
import soundfile as sf
|
| 10 |
+
from scipy.signal import butter, filtfilt
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AudioProcessor:
|
| 16 |
+
"""Process audio files for Myanmar speech recognition."""
|
| 17 |
+
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 20 |
+
sample_rate: int = 16000,
|
| 21 |
+
n_fft: int = 512,
|
| 22 |
+
hop_length: int = 160,
|
| 23 |
+
n_mels: int = 80,
|
| 24 |
+
):
|
| 25 |
+
self.sample_rate = sample_rate
|
| 26 |
+
self.n_fft = n_fft
|
| 27 |
+
self.hop_length = hop_length
|
| 28 |
+
self.n_mels = n_mels
|
| 29 |
+
|
| 30 |
+
def load_audio(self, path: str) -> Tuple[np.ndarray, int]:
|
| 31 |
+
"""Load audio file and resample to target sample rate."""
|
| 32 |
+
audio, sr = librosa.load(path, sr=self.sample_rate)
|
| 33 |
+
logger.info(f"Loaded audio from {path}: {len(audio)} samples at {sr}Hz")
|
| 34 |
+
return audio, sr
|
| 35 |
+
|
| 36 |
+
def normalize_audio(self, audio: np.ndarray) -> np.ndarray:
|
| 37 |
+
"""Normalize audio to [-1, 1] range."""
|
| 38 |
+
max_val = np.abs(audio).max()
|
| 39 |
+
if max_val > 0:
|
| 40 |
+
audio = audio / max_val
|
| 41 |
+
return audio
|
| 42 |
+
|
| 43 |
+
def remove_silence(
|
| 44 |
+
self,
|
| 45 |
+
audio: np.ndarray,
|
| 46 |
+
threshold_db: float = -40,
|
| 47 |
+
min_silence_duration: float = 0.3,
|
| 48 |
+
) -> np.ndarray:
|
| 49 |
+
"""Remove silence from audio based on energy threshold."""
|
| 50 |
+
intervals = librosa.effects.split(
|
| 51 |
+
audio,
|
| 52 |
+
top_db=-threshold_db,
|
| 53 |
+
frame_length=self.n_fft,
|
| 54 |
+
hop_length=self.hop_length,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
if len(intervals) == 0:
|
| 58 |
+
return audio
|
| 59 |
+
|
| 60 |
+
min_samples = int(min_silence_duration * self.sample_rate)
|
| 61 |
+
non_silent = []
|
| 62 |
+
|
| 63 |
+
for start, end in intervals:
|
| 64 |
+
if end - start >= min_samples:
|
| 65 |
+
non_silent.append(audio[start:end])
|
| 66 |
+
|
| 67 |
+
if non_silent:
|
| 68 |
+
return np.concatenate(non_silent)
|
| 69 |
+
return audio
|
| 70 |
+
|
| 71 |
+
def apply_bandpass_filter(
|
| 72 |
+
self,
|
| 73 |
+
audio: np.ndarray,
|
| 74 |
+
low_freq: float = 80,
|
| 75 |
+
high_freq: float = 7500,
|
| 76 |
+
) -> np.ndarray:
|
| 77 |
+
"""Apply bandpass filter to focus on speech frequencies."""
|
| 78 |
+
nyquist = self.sample_rate / 2
|
| 79 |
+
low = low_freq / nyquist
|
| 80 |
+
high = high_freq / nyquist
|
| 81 |
+
|
| 82 |
+
if low < 0:
|
| 83 |
+
low = 0.001
|
| 84 |
+
if high > 1:
|
| 85 |
+
high = 0.999
|
| 86 |
+
|
| 87 |
+
b, a = butter(4, [low, high], btype="band")
|
| 88 |
+
filtered = filtfilt(b, a, audio)
|
| 89 |
+
return filtered
|
| 90 |
+
|
| 91 |
+
def reduce_noise(
|
| 92 |
+
self,
|
| 93 |
+
audio: np.ndarray,
|
| 94 |
+
noise_profile: Optional[np.ndarray] = None,
|
| 95 |
+
) -> np.ndarray:
|
| 96 |
+
"""Reduce background noise using spectral subtraction."""
|
| 97 |
+
if noise_profile is None:
|
| 98 |
+
noise_profile = audio[: int(0.1 * self.sample_rate)]
|
| 99 |
+
|
| 100 |
+
noise_spectrum = np.abs(np.fft.rfft(noise_profile))
|
| 101 |
+
noise_magnitude = np.mean(noise_spectrum, axis=0)
|
| 102 |
+
|
| 103 |
+
audio_spectrum = np.abs(np.fft.rfft(audio))
|
| 104 |
+
cleaned = np.maximum(
|
| 105 |
+
audio_spectrum - noise_magnitude[:, None],
|
| 106 |
+
audio_spectrum * 0.1,
|
| 107 |
+
)
|
| 108 |
+
cleaned = cleaned * np.exp(1j * np.fft.rfft(audio).angle())
|
| 109 |
+
|
| 110 |
+
return np.fft.irfft(cleaned)
|
| 111 |
+
|
| 112 |
+
def extract_mel_spectrogram(self, audio: np.ndarray) -> np.ndarray:
|
| 113 |
+
"""Extract mel spectrogram features."""
|
| 114 |
+
mel_spec = librosa.feature.melspectrogram(
|
| 115 |
+
y=audio,
|
| 116 |
+
sr=self.sample_rate,
|
| 117 |
+
n_fft=self.n_fft,
|
| 118 |
+
hop_length=self.hop_length,
|
| 119 |
+
n_mels=self.n_mels,
|
| 120 |
+
)
|
| 121 |
+
log_mel = librosa.power_to_db(mel_spec, ref=np.max)
|
| 122 |
+
return log_mel
|
| 123 |
+
|
| 124 |
+
def extract_prosody_features(self, audio: np.ndarray) -> dict:
|
| 125 |
+
"""Extract prosodic features (pitch, energy, speaking rate)."""
|
| 126 |
+
pitches, magnitudes = librosa.piptrack(
|
| 127 |
+
y=audio,
|
| 128 |
+
sr=self.sample_rate,
|
| 129 |
+
n_fft=self.n_fft,
|
| 130 |
+
hop_length=self.hop_length,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
pitch_values = []
|
| 134 |
+
for i in range(pitches.shape[1]):
|
| 135 |
+
index = magnitudes[:, i].argmax()
|
| 136 |
+
pitch = pitches[index, i]
|
| 137 |
+
if pitch > 0:
|
| 138 |
+
pitch_values.append(pitch)
|
| 139 |
+
|
| 140 |
+
rms = librosa.feature.rms(y=audio, hop_length=self.hop_length)[0]
|
| 141 |
+
|
| 142 |
+
return {
|
| 143 |
+
"mean_pitch": np.mean(pitch_values) if pitch_values else 0,
|
| 144 |
+
"pitch_std": np.std(pitch_values) if pitch_values else 0,
|
| 145 |
+
"pitch_range": (np.min(pitch_values) if pitch_values else 0,
|
| 146 |
+
np.max(pitch_values) if pitch_values else 0),
|
| 147 |
+
"mean_energy": np.mean(rms),
|
| 148 |
+
"energy_std": np.std(rms),
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
def process_file(
|
| 152 |
+
self,
|
| 153 |
+
input_path: str,
|
| 154 |
+
output_path: str,
|
| 155 |
+
remove_silence: bool = True,
|
| 156 |
+
apply_filter: bool = True,
|
| 157 |
+
) -> dict:
|
| 158 |
+
"""Process a single audio file."""
|
| 159 |
+
audio, sr = self.load_audio(input_path)
|
| 160 |
+
audio = self.normalize_audio(audio)
|
| 161 |
+
|
| 162 |
+
if apply_filter:
|
| 163 |
+
audio = self.apply_bandpass_filter(audio)
|
| 164 |
+
|
| 165 |
+
if remove_silence:
|
| 166 |
+
audio = self.remove_silence(audio)
|
| 167 |
+
|
| 168 |
+
prosody = self.extract_prosody_features(audio)
|
| 169 |
+
|
| 170 |
+
sf.write(output_path, audio, self.sample_rate)
|
| 171 |
+
logger.info(f"Saved processed audio to {output_path}")
|
| 172 |
+
|
| 173 |
+
return {
|
| 174 |
+
"input_path": input_path,
|
| 175 |
+
"output_path": output_path,
|
| 176 |
+
"duration": len(audio) / self.sample_rate,
|
| 177 |
+
"prosody": prosody,
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
def batch_process(
|
| 181 |
+
self,
|
| 182 |
+
input_dir: str,
|
| 183 |
+
output_dir: str,
|
| 184 |
+
pattern: str = "*.wav",
|
| 185 |
+
) -> list:
|
| 186 |
+
"""Process all audio files in a directory."""
|
| 187 |
+
input_path = Path(input_dir)
|
| 188 |
+
output_path = Path(output_dir)
|
| 189 |
+
output_path.mkdir(parents=True, exist_ok=True)
|
| 190 |
+
|
| 191 |
+
results = []
|
| 192 |
+
for file_path in input_path.glob(pattern):
|
| 193 |
+
out_file = output_path / file_path.name
|
| 194 |
+
result = self.process_file(str(file_path), str(out_file))
|
| 195 |
+
results.append(result)
|
| 196 |
+
|
| 197 |
+
return results
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def create_processor(config: dict = None) -> AudioProcessor:
|
| 201 |
+
"""Factory function to create AudioProcessor from config."""
|
| 202 |
+
if config is None:
|
| 203 |
+
config = {}
|
| 204 |
+
|
| 205 |
+
return AudioProcessor(
|
| 206 |
+
sample_rate=config.get("sample_rate", 16000),
|
| 207 |
+
n_fft=config.get("n_fft", 512),
|
| 208 |
+
hop_length=config.get("hop_length", 160),
|
| 209 |
+
n_mels=config.get("n_mels", 80),
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
if __name__ == "__main__":
|
| 214 |
+
processor = create_processor()
|
| 215 |
+
print("AudioProcessor initialized successfully")
|
data_processing/graph_builder.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Knowledge Graph builder for Myanmar Ghost project.
|
| 2 |
+
|
| 3 |
+
Represents conversational context as a knowledge graph for better
|
| 4 |
+
understanding of complex social interactions.
|
| 5 |
+
Example: (Speaker, Role, Customer) --[located_in]--> (Restaurant)
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
from enum import Enum
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
| 13 |
+
|
| 14 |
+
import networkx as nx
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class NodeType(str, Enum):
|
| 18 |
+
"""Types of nodes in the knowledge graph."""
|
| 19 |
+
SPEAKER = "speaker"
|
| 20 |
+
UTTERANCE = "utterance"
|
| 21 |
+
LOCATION = "location"
|
| 22 |
+
ORGANIZATION = "organization"
|
| 23 |
+
EMOTION = "emotion"
|
| 24 |
+
TOPIC = "topic"
|
| 25 |
+
ACTION = "action"
|
| 26 |
+
TIME = "time"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class RelationType(str, Enum):
|
| 30 |
+
"""Types of relations between nodes."""
|
| 31 |
+
SPEAKS = "speaks"
|
| 32 |
+
LOCATED_IN = "located_in"
|
| 33 |
+
WORKS_AT = "works_at"
|
| 34 |
+
VISITS = "visits"
|
| 35 |
+
FEELS = "feels"
|
| 36 |
+
ABOUT = "about"
|
| 37 |
+
BEFORE = "before"
|
| 38 |
+
AFTER = "after"
|
| 39 |
+
IN_RESPONSE_TO = "in_response_to"
|
| 40 |
+
CONTAINS = "contains"
|
| 41 |
+
HAS_ROLE = "has_role"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class Entity:
|
| 46 |
+
"""Represents an entity in the knowledge graph."""
|
| 47 |
+
id: str
|
| 48 |
+
type: NodeType
|
| 49 |
+
properties: Dict[str, Any] = field(default_factory=dict)
|
| 50 |
+
aliases: List[str] = field(default_factory=list)
|
| 51 |
+
|
| 52 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 53 |
+
return {
|
| 54 |
+
"id": self.id,
|
| 55 |
+
"type": self.type.value,
|
| 56 |
+
"properties": self.properties,
|
| 57 |
+
"aliases": self.aliases,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass
|
| 62 |
+
class Relation:
|
| 63 |
+
"""Represents a relation between entities."""
|
| 64 |
+
source: str # Entity ID
|
| 65 |
+
target: str # Entity ID
|
| 66 |
+
type: RelationType
|
| 67 |
+
properties: Dict[str, Any] = field(default_factory=dict)
|
| 68 |
+
confidence: float = 1.0
|
| 69 |
+
|
| 70 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 71 |
+
return {
|
| 72 |
+
"source": self.source,
|
| 73 |
+
"target": self.target,
|
| 74 |
+
"type": self.type.value,
|
| 75 |
+
"properties": self.properties,
|
| 76 |
+
"confidence": self.confidence,
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class MyanmarKnowledgeGraph:
|
| 81 |
+
"""Build and manage knowledge graph for Myanmar conversations."""
|
| 82 |
+
|
| 83 |
+
# Common Myanmar entities
|
| 84 |
+
LOCATIONS = {
|
| 85 |
+
"စားသောက်ဆိုင်": NodeType.LOCATION,
|
| 86 |
+
"ဆေးရုံ": NodeType.LOCATION,
|
| 87 |
+
"ဈေး": NodeType.LOCATION,
|
| 88 |
+
"ရုံး": NodeType.LOCATION,
|
| 89 |
+
"အိမ်": NodeType.LOCATION,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
EMOTIONS = {
|
| 93 |
+
"ပျော်": NodeType.EMOTION,
|
| 94 |
+
"စိတ်ဓာတ်ကျ": NodeType.EMOTION,
|
| 95 |
+
"ဒေါသ": NodeType.EMOTION,
|
| 96 |
+
"ဝမ်းနည်း": NodeType.EMOTION,
|
| 97 |
+
"ပိုးပါး": NodeType.EMOTION,
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
ROLES = {
|
| 101 |
+
"ဖေါ်သည်": "customer",
|
| 102 |
+
"ဝန်ထမ်း": "staff",
|
| 103 |
+
"ဆရာဝန်": "doctor",
|
| 104 |
+
"ပါးရှင်း": "patient",
|
| 105 |
+
"အရာရှိ": "manager",
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
def __init__(self):
|
| 109 |
+
self.graph = nx.MultiDiGraph()
|
| 110 |
+
self.entity_index: Dict[str, Entity] = {}
|
| 111 |
+
self.session_id = 0
|
| 112 |
+
|
| 113 |
+
def add_entity(self, entity: Entity) -> None:
|
| 114 |
+
"""Add an entity to the graph."""
|
| 115 |
+
self.entity_index[entity.id] = entity
|
| 116 |
+
self.graph.add_node(
|
| 117 |
+
entity.id,
|
| 118 |
+
type=entity.type.value,
|
| 119 |
+
**entity.properties,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
def add_relation(self, relation: Relation) -> None:
|
| 123 |
+
"""Add a relation between entities."""
|
| 124 |
+
self.graph.add_edge(
|
| 125 |
+
relation.source,
|
| 126 |
+
relation.target,
|
| 127 |
+
type=relation.type.value,
|
| 128 |
+
**relation.properties,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
def extract_speaker_entity(
|
| 132 |
+
self,
|
| 133 |
+
speaker_id: str,
|
| 134 |
+
role: Optional[str] = None,
|
| 135 |
+
) -> Entity:
|
| 136 |
+
"""Create a speaker entity from utterance metadata."""
|
| 137 |
+
entity = Entity(
|
| 138 |
+
id=f"speaker_{speaker_id}",
|
| 139 |
+
type=NodeType.SPEAKER,
|
| 140 |
+
properties={
|
| 141 |
+
"role": role or "unknown",
|
| 142 |
+
"session": self.session_id,
|
| 143 |
+
},
|
| 144 |
+
)
|
| 145 |
+
self.add_entity(entity)
|
| 146 |
+
return entity
|
| 147 |
+
|
| 148 |
+
def extract_utterance_entity(
|
| 149 |
+
self,
|
| 150 |
+
text: str,
|
| 151 |
+
speaker_id: str,
|
| 152 |
+
timestamp: float,
|
| 153 |
+
prosody: Optional[Dict] = None,
|
| 154 |
+
) -> Tuple[Entity, List[Entity], List[Relation]]:
|
| 155 |
+
"""Extract utterance and related entities from text."""
|
| 156 |
+
utterance_id = f"utt_{speaker_id}_{int(timestamp * 1000)}"
|
| 157 |
+
|
| 158 |
+
utterance = Entity(
|
| 159 |
+
id=utterance_id,
|
| 160 |
+
type=NodeType.UTTERANCE,
|
| 161 |
+
properties={
|
| 162 |
+
"text": text,
|
| 163 |
+
"timestamp": timestamp,
|
| 164 |
+
"prosody": prosody or {},
|
| 165 |
+
},
|
| 166 |
+
)
|
| 167 |
+
self.add_entity(utterance)
|
| 168 |
+
|
| 169 |
+
# Extract related entities
|
| 170 |
+
related_entities = []
|
| 171 |
+
relations = []
|
| 172 |
+
|
| 173 |
+
# Extract location mentions
|
| 174 |
+
for loc, _ in self.LOCATIONS.items():
|
| 175 |
+
if loc in text:
|
| 176 |
+
loc_entity = Entity(
|
| 177 |
+
id=f"loc_{loc}_{self.session_id}",
|
| 178 |
+
type=NodeType.LOCATION,
|
| 179 |
+
properties={"name": loc},
|
| 180 |
+
)
|
| 181 |
+
self.add_entity(loc_entity)
|
| 182 |
+
related_entities.append(loc_entity)
|
| 183 |
+
|
| 184 |
+
relation = Relation(
|
| 185 |
+
source=utterance_id,
|
| 186 |
+
target=loc_entity.id,
|
| 187 |
+
type=RelationType.LOCATED_IN,
|
| 188 |
+
)
|
| 189 |
+
self.add_relation(relation)
|
| 190 |
+
relations.append(relation)
|
| 191 |
+
|
| 192 |
+
# Extract emotion mentions
|
| 193 |
+
for emotion, _ in self.EMOTIONS.items():
|
| 194 |
+
if emotion in text:
|
| 195 |
+
emotion_entity = Entity(
|
| 196 |
+
id=f"emotion_{emotion}_{self.session_id}",
|
| 197 |
+
type=NodeType.EMOTION,
|
| 198 |
+
properties={"name": emotion},
|
| 199 |
+
)
|
| 200 |
+
self.add_entity(emotion_entity)
|
| 201 |
+
related_entities.append(emotion_entity)
|
| 202 |
+
|
| 203 |
+
relation = Relation(
|
| 204 |
+
source=utterance_id,
|
| 205 |
+
target=emotion_entity.id,
|
| 206 |
+
type=RelationType.FEELS,
|
| 207 |
+
)
|
| 208 |
+
self.add_relation(relation)
|
| 209 |
+
relations.append(relation)
|
| 210 |
+
|
| 211 |
+
# Link to speaker
|
| 212 |
+
speaker_entity = self.entity_index.get(f"speaker_{speaker_id}")
|
| 213 |
+
if speaker_entity:
|
| 214 |
+
relation = Relation(
|
| 215 |
+
source=speaker_entity.id,
|
| 216 |
+
target=utterance_id,
|
| 217 |
+
type=RelationType.SPEAKS,
|
| 218 |
+
)
|
| 219 |
+
self.add_relation(relation)
|
| 220 |
+
relations.append(relation)
|
| 221 |
+
|
| 222 |
+
return utterance, related_entities, relations
|
| 223 |
+
|
| 224 |
+
def build_from_conversation(
|
| 225 |
+
self,
|
| 226 |
+
utterances: List[Dict],
|
| 227 |
+
context: Optional[Dict] = None,
|
| 228 |
+
) -> nx.MultiDiGraph:
|
| 229 |
+
"""Build knowledge graph from conversation data."""
|
| 230 |
+
self.session_id += 1
|
| 231 |
+
|
| 232 |
+
# Set context entities
|
| 233 |
+
if context:
|
| 234 |
+
for key, value in context.items():
|
| 235 |
+
if key == "location" and value in self.LOCATIONS:
|
| 236 |
+
loc_entity = Entity(
|
| 237 |
+
id=f"context_location",
|
| 238 |
+
type=NodeType.LOCATION,
|
| 239 |
+
properties={"name": value},
|
| 240 |
+
)
|
| 241 |
+
self.add_entity(loc_entity)
|
| 242 |
+
|
| 243 |
+
prev_utterance = None
|
| 244 |
+
|
| 245 |
+
for i, utt_data in enumerate(utterances):
|
| 246 |
+
speaker_id = utt_data.get("speaker_id", f"s_{i}")
|
| 247 |
+
text = utt_data.get("text", "")
|
| 248 |
+
timestamp = utt_data.get("timestamp", i)
|
| 249 |
+
prosody = utt_data.get("prosody")
|
| 250 |
+
role = utt_data.get("role")
|
| 251 |
+
|
| 252 |
+
# Add speaker
|
| 253 |
+
self.extract_speaker_entity(speaker_id, role)
|
| 254 |
+
|
| 255 |
+
# Add utterance
|
| 256 |
+
utterance, related, _ = self.extract_utterance_entity(
|
| 257 |
+
text, speaker_id, timestamp, prosody
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
# Link to previous utterance (temporal relation)
|
| 261 |
+
if prev_utterance:
|
| 262 |
+
relation = Relation(
|
| 263 |
+
source=prev_utterance.id,
|
| 264 |
+
target=utterance.id,
|
| 265 |
+
type=RelationType.BEFORE,
|
| 266 |
+
)
|
| 267 |
+
self.add_relation(relation)
|
| 268 |
+
|
| 269 |
+
# In response relation
|
| 270 |
+
response_relation = Relation(
|
| 271 |
+
source=utterance.id,
|
| 272 |
+
target=prev_utterance.id,
|
| 273 |
+
type=RelationType.IN_RESPONSE_TO,
|
| 274 |
+
)
|
| 275 |
+
self.add_relation(response_relation)
|
| 276 |
+
|
| 277 |
+
prev_utterance = utterance
|
| 278 |
+
|
| 279 |
+
return self.graph
|
| 280 |
+
|
| 281 |
+
def query_path(
|
| 282 |
+
self,
|
| 283 |
+
source_type: NodeType,
|
| 284 |
+
target_type: NodeType,
|
| 285 |
+
relation_type: Optional[RelationType] = None,
|
| 286 |
+
) -> List[Tuple[Entity, Entity, Relation]]:
|
| 287 |
+
"""Query paths between entity types."""
|
| 288 |
+
results = []
|
| 289 |
+
|
| 290 |
+
for source_id in self.entity_index:
|
| 291 |
+
source = self.entity_index[source_id]
|
| 292 |
+
if source.type != source_type:
|
| 293 |
+
continue
|
| 294 |
+
|
| 295 |
+
for target_id in self.entity_index:
|
| 296 |
+
target = self.entity_index[target_id]
|
| 297 |
+
if target.type != target_type:
|
| 298 |
+
continue
|
| 299 |
+
|
| 300 |
+
# Find paths
|
| 301 |
+
try:
|
| 302 |
+
if relation_type:
|
| 303 |
+
edges = self.graph.get_edge_data(source_id, target_id)
|
| 304 |
+
if edges:
|
| 305 |
+
for edge_data in edges.values():
|
| 306 |
+
if edge_data.get("type") == relation_type.value:
|
| 307 |
+
relation = Relation(
|
| 308 |
+
source=source_id,
|
| 309 |
+
target=target_id,
|
| 310 |
+
type=relation_type,
|
| 311 |
+
properties=edge_data,
|
| 312 |
+
)
|
| 313 |
+
results.append((source, target, relation))
|
| 314 |
+
else:
|
| 315 |
+
if nx.has_path(self.graph, source_id, target_id):
|
| 316 |
+
path = nx.shortest_path(
|
| 317 |
+
self.graph, source_id, target_id
|
| 318 |
+
)
|
| 319 |
+
if len(path) == 2:
|
| 320 |
+
relation = Relation(
|
| 321 |
+
source=source_id,
|
| 322 |
+
target=target_id,
|
| 323 |
+
type=RelationType.CONTAINS,
|
| 324 |
+
)
|
| 325 |
+
results.append((source, target, relation))
|
| 326 |
+
except nx.NetworkXError:
|
| 327 |
+
continue
|
| 328 |
+
|
| 329 |
+
return results
|
| 330 |
+
|
| 331 |
+
def get_utterance_context(self, utterance_id: str) -> Dict:
|
| 332 |
+
"""Get full context for an utterance."""
|
| 333 |
+
if utterance_id not in self.entity_index:
|
| 334 |
+
return {}
|
| 335 |
+
|
| 336 |
+
context = {
|
| 337 |
+
"utterance": self.entity_index[utterance_id].to_dict(),
|
| 338 |
+
"speaker": None,
|
| 339 |
+
"previous": None,
|
| 340 |
+
"next": None,
|
| 341 |
+
"locations": [],
|
| 342 |
+
"emotions": [],
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
# Get speaker
|
| 346 |
+
for edge in self.graph.out_edges(utterance_id, data=True):
|
| 347 |
+
if edge[2].get("type") == RelationType.FEELS.value:
|
| 348 |
+
context["emotions"].append(self.entity_index[edge[1]].to_dict())
|
| 349 |
+
if edge[2].get("type") == RelationType.LOCATED_IN.value:
|
| 350 |
+
context["locations"].append(self.entity_index[edge[1]].to_dict())
|
| 351 |
+
|
| 352 |
+
# Get predecessor/successor
|
| 353 |
+
predecessors = list(self.graph.predecessors(utterance_id))
|
| 354 |
+
successors = list(self.graph.successors(utterance_id))
|
| 355 |
+
|
| 356 |
+
for pred_id in predecessors:
|
| 357 |
+
pred = self.entity_index.get(pred_id)
|
| 358 |
+
if pred and pred.type == NodeType.UTTERANCE:
|
| 359 |
+
context["previous"] = pred.to_dict()
|
| 360 |
+
break
|
| 361 |
+
|
| 362 |
+
for succ_id in successors:
|
| 363 |
+
succ = self.entity_index.get(succ_id)
|
| 364 |
+
if succ and succ.type == NodeType.UTTERANCE:
|
| 365 |
+
context["next"] = succ.to_dict()
|
| 366 |
+
break
|
| 367 |
+
|
| 368 |
+
return context
|
| 369 |
+
|
| 370 |
+
def export_to_json(self, path: str) -> None:
|
| 371 |
+
"""Export graph to JSON format."""
|
| 372 |
+
entities = [e.to_dict() for e in self.entity_index.values()]
|
| 373 |
+
|
| 374 |
+
relations = []
|
| 375 |
+
for source, target, data in self.graph.edges(data=True):
|
| 376 |
+
relations.append({
|
| 377 |
+
"source": source,
|
| 378 |
+
"target": target,
|
| 379 |
+
"type": data.get("type"),
|
| 380 |
+
**data,
|
| 381 |
+
})
|
| 382 |
+
|
| 383 |
+
output = {
|
| 384 |
+
"entities": entities,
|
| 385 |
+
"relations": relations,
|
| 386 |
+
"metadata": {
|
| 387 |
+
"num_entities": len(entities),
|
| 388 |
+
"num_relations": len(relations),
|
| 389 |
+
"session_id": self.session_id,
|
| 390 |
+
},
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 394 |
+
json.dump(output, f, indent=2, ensure_ascii=False)
|
| 395 |
+
|
| 396 |
+
def load_from_json(self, path: str) -> None:
|
| 397 |
+
"""Load graph from JSON format."""
|
| 398 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 399 |
+
data = json.load(f)
|
| 400 |
+
|
| 401 |
+
self.entity_index = {}
|
| 402 |
+
self.graph = nx.MultiDiGraph()
|
| 403 |
+
|
| 404 |
+
for entity_data in data.get("entities", []):
|
| 405 |
+
entity = Entity(
|
| 406 |
+
id=entity_data["id"],
|
| 407 |
+
type=NodeType(entity_data["type"]),
|
| 408 |
+
properties=entity_data.get("properties", {}),
|
| 409 |
+
aliases=entity_data.get("aliases", []),
|
| 410 |
+
)
|
| 411 |
+
self.add_entity(entity)
|
| 412 |
+
|
| 413 |
+
for rel_data in data.get("relations", []):
|
| 414 |
+
relation = Relation(
|
| 415 |
+
source=rel_data["source"],
|
| 416 |
+
target=rel_data["target"],
|
| 417 |
+
type=RelationType(rel_data["type"]),
|
| 418 |
+
properties=rel_data,
|
| 419 |
+
confidence=rel_data.get("confidence", 1.0),
|
| 420 |
+
)
|
| 421 |
+
self.add_relation(relation)
|
| 422 |
+
|
| 423 |
+
def visualize(self) -> nx.MultiDiGraph:
|
| 424 |
+
"""Return the graph for visualization."""
|
| 425 |
+
return self.graph
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def create_knowledge_graph() -> MyanmarKnowledgeGraph:
|
| 429 |
+
"""Factory function to create knowledge graph."""
|
| 430 |
+
return MyanmarKnowledgeGraph()
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
if __name__ == "__main__":
|
| 434 |
+
# Example usage
|
| 435 |
+
kg = create_knowledge_graph()
|
| 436 |
+
|
| 437 |
+
# Sample conversation
|
| 438 |
+
utterances = [
|
| 439 |
+
{
|
| 440 |
+
"speaker_id": "customer_1",
|
| 441 |
+
"text": "ဆိုင်သို့ ကျွန်ုပ်လာပါပြီ",
|
| 442 |
+
"timestamp": 0,
|
| 443 |
+
"role": "customer",
|
| 444 |
+
},
|
| 445 |
+
{
|
| 446 |
+
"speaker_id": "staff_1",
|
| 447 |
+
"text": "ကြိုဆိုပါတယ်",
|
| 448 |
+
"timestamp": 1,
|
| 449 |
+
"role": "staff",
|
| 450 |
+
},
|
| 451 |
+
{
|
| 452 |
+
"speaker_id": "customer_1",
|
| 453 |
+
"text": "ကျေးဇူးပါ",
|
| 454 |
+
"timestamp": 2,
|
| 455 |
+
"prosody": {"mean_pitch": 150, "speaking_rate": 3},
|
| 456 |
+
"role": "customer",
|
| 457 |
+
},
|
| 458 |
+
]
|
| 459 |
+
|
| 460 |
+
context = {"location": "စားသောက်ဆိုင်"}
|
| 461 |
+
|
| 462 |
+
kg.build_from_conversation(utterances, context)
|
| 463 |
+
|
| 464 |
+
# Export
|
| 465 |
+
kg.export_to_json("data/graph/conversation_graph.json")
|
| 466 |
+
print(f"Graph exported with {len(kg.entity_index)} entities")
|
data_processing/multimodal_fusion.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-modal data fusion for Myanmar Ghost project.
|
| 2 |
+
|
| 3 |
+
Fuses audio (prosody) and text to understand sentiment/intensity
|
| 4 |
+
in expressions like "ကျေးဇူးပါ" (thank you) which can mean:
|
| 5 |
+
- Genuine gratitude (low pitch, slow)
|
| 6 |
+
- Sarcasm (high pitch, fast)
|
| 7 |
+
- Complaint (negative prosody)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from enum import Enum
|
| 12 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn as nn
|
| 17 |
+
from torch import Tensor
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class SentimentClass(str, Enum):
|
| 21 |
+
"""Sentiment classes for thanking expressions."""
|
| 22 |
+
GENUINE = "genuine" # ရိုးသားခြင်း
|
| 23 |
+
SARCASTIC = "sarcastic" # သရော်ခြင်း
|
| 24 |
+
COMPLAINING = "complaining" # မကျေနပ်ခြင်း
|
| 25 |
+
NEUTRAL = "neutral"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class ProsodyFeatures:
|
| 30 |
+
"""Prosodic features extracted from audio."""
|
| 31 |
+
mean_pitch: float
|
| 32 |
+
pitch_std: float
|
| 33 |
+
pitch_range: Tuple[float, float]
|
| 34 |
+
mean_energy: float
|
| 35 |
+
energy_std: float
|
| 36 |
+
speaking_rate: float # syllables per second
|
| 37 |
+
pause_duration: float # total pause time in seconds
|
| 38 |
+
|
| 39 |
+
def to_tensor(self) -> Tensor:
|
| 40 |
+
"""Convert to PyTorch tensor."""
|
| 41 |
+
return torch.tensor([
|
| 42 |
+
self.mean_pitch,
|
| 43 |
+
self.pitch_std,
|
| 44 |
+
self.pitch_range[0],
|
| 45 |
+
self.pitch_range[1],
|
| 46 |
+
self.mean_energy,
|
| 47 |
+
self.energy_std,
|
| 48 |
+
self.speaking_rate,
|
| 49 |
+
self.pause_duration,
|
| 50 |
+
], dtype=torch.float32)
|
| 51 |
+
|
| 52 |
+
def to_dict(self) -> Dict[str, float]:
|
| 53 |
+
"""Convert to dictionary."""
|
| 54 |
+
return {
|
| 55 |
+
"mean_pitch": self.mean_pitch,
|
| 56 |
+
"pitch_std": self.pitch_std,
|
| 57 |
+
"pitch_min": self.pitch_range[0],
|
| 58 |
+
"pitch_max": self.pitch_range[1],
|
| 59 |
+
"mean_energy": self.mean_energy,
|
| 60 |
+
"energy_std": self.energy_std,
|
| 61 |
+
"speaking_rate": self.speaking_rate,
|
| 62 |
+
"pause_duration": self.pause_duration,
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass
|
| 67 |
+
class TextFeatures:
|
| 68 |
+
"""Text-based features for sentiment analysis."""
|
| 69 |
+
text_length: int
|
| 70 |
+
word_count: int
|
| 71 |
+
contains_intensifier: bool # e.g., "အရမ်း", "များစွာ"
|
| 72 |
+
politeness_level: int # 1-5 scale
|
| 73 |
+
formality: float # 0-1 scale
|
| 74 |
+
|
| 75 |
+
def to_tensor(self) -> Tensor:
|
| 76 |
+
"""Convert to PyTorch tensor."""
|
| 77 |
+
return torch.tensor([
|
| 78 |
+
float(self.text_length),
|
| 79 |
+
float(self.word_count),
|
| 80 |
+
float(self.contains_intensifier),
|
| 81 |
+
float(self.politeness_level),
|
| 82 |
+
self.formality,
|
| 83 |
+
], dtype=torch.float32)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@dataclass
|
| 87 |
+
class FusedFeatures:
|
| 88 |
+
"""Combined multi-modal features."""
|
| 89 |
+
prosody: ProsodyFeatures
|
| 90 |
+
text: TextFeatures
|
| 91 |
+
sentiment_hint: Optional[SentimentClass] = None
|
| 92 |
+
|
| 93 |
+
def concat_tensors(self) -> Tensor:
|
| 94 |
+
"""Concatenate all features into single tensor."""
|
| 95 |
+
return torch.cat([
|
| 96 |
+
self.prosody.to_tensor(),
|
| 97 |
+
self.text.to_tensor(),
|
| 98 |
+
])
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class ProsodyExtractor:
|
| 102 |
+
"""Extract prosodic features from audio."""
|
| 103 |
+
|
| 104 |
+
# Prosody patterns for different sentiments
|
| 105 |
+
GENUINE_PATTERN = {
|
| 106 |
+
"pitch_range": (50, 200), # Hz
|
| 107 |
+
"speaking_rate": (2, 4), # syllables/sec
|
| 108 |
+
"energy_std": (0.1, 0.3),
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
SARCASTIC_PATTERN = {
|
| 112 |
+
"pitch_range": (200, 400),
|
| 113 |
+
"speaking_rate": (4, 8),
|
| 114 |
+
"energy_std": (0.3, 0.6),
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
COMPLAINING_PATTERN = {
|
| 118 |
+
"pitch_range": (100, 250),
|
| 119 |
+
"speaking_rate": (3, 6),
|
| 120 |
+
"energy_std": (0.2, 0.5),
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
def extract_from_audio(
|
| 124 |
+
self,
|
| 125 |
+
audio: np.ndarray,
|
| 126 |
+
sample_rate: int = 16000,
|
| 127 |
+
) -> ProsodyFeatures:
|
| 128 |
+
"""Extract prosodic features from audio signal."""
|
| 129 |
+
import librosa
|
| 130 |
+
|
| 131 |
+
# Pitch tracking
|
| 132 |
+
pitches, magnitudes = librosa.piptrack(
|
| 133 |
+
y=audio,
|
| 134 |
+
sr=sample_rate,
|
| 135 |
+
n_fft=512,
|
| 136 |
+
hop_length=160,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
pitch_values = []
|
| 140 |
+
for i in range(pitches.shape[1]):
|
| 141 |
+
index = magnitudes[:, i].argmax()
|
| 142 |
+
pitch = pitches[index, i]
|
| 143 |
+
if pitch > 0:
|
| 144 |
+
pitch_values.append(pitch)
|
| 145 |
+
|
| 146 |
+
# Energy
|
| 147 |
+
rms = librosa.feature.rms(y=audio, hop_length=160)[0]
|
| 148 |
+
|
| 149 |
+
# Speaking rate (syllable detection)
|
| 150 |
+
onsets = librosa.onset.onset_detect(
|
| 151 |
+
y=audio,
|
| 152 |
+
sr=sample_rate,
|
| 153 |
+
hop_length=160,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
duration = len(audio) / sample_rate
|
| 157 |
+
speaking_rate = len(onsets) / duration if duration > 0 else 0
|
| 158 |
+
|
| 159 |
+
# Pause detection
|
| 160 |
+
energy_threshold = np.percentile(rms, 25)
|
| 161 |
+
pauses = rms < energy_threshold
|
| 162 |
+
pause_duration = np.sum(pauses) * 160 / sample_rate
|
| 163 |
+
|
| 164 |
+
return ProsodyFeatures(
|
| 165 |
+
mean_pitch=np.mean(pitch_values) if pitch_values else 0,
|
| 166 |
+
pitch_std=np.std(pitch_values) if pitch_values else 0,
|
| 167 |
+
pitch_range=(
|
| 168 |
+
np.min(pitch_values) if pitch_values else 0,
|
| 169 |
+
np.max(pitch_values) if pitch_values else 0,
|
| 170 |
+
),
|
| 171 |
+
mean_energy=np.mean(rms),
|
| 172 |
+
energy_std=np.std(rms),
|
| 173 |
+
speaking_rate=speaking_rate,
|
| 174 |
+
pause_duration=pause_duration,
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
def infer_sentiment(self, prosody: ProsodyFeatures) -> SentimentClass:
|
| 178 |
+
"""Infer sentiment from prosodic features."""
|
| 179 |
+
patterns = [
|
| 180 |
+
(SentimentClass.GENUINE, self.GENUINE_PATTERN),
|
| 181 |
+
(SentimentClass.SARCASTIC, self.SARCASTIC_PATTERN),
|
| 182 |
+
(SentimentClass.COMPLAINING, self.COMPLAINING_PATTERN),
|
| 183 |
+
]
|
| 184 |
+
|
| 185 |
+
scores = {}
|
| 186 |
+
for sentiment, pattern in patterns:
|
| 187 |
+
score = 0
|
| 188 |
+
features = prosody.to_dict()
|
| 189 |
+
|
| 190 |
+
for key, (low, high) in pattern.items():
|
| 191 |
+
if key in features:
|
| 192 |
+
value = features[key]
|
| 193 |
+
if low <= value <= high:
|
| 194 |
+
score += 1
|
| 195 |
+
|
| 196 |
+
scores[sentiment] = score
|
| 197 |
+
|
| 198 |
+
return max(scores, key=scores.get)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
class TextFeatureExtractor:
|
| 202 |
+
"""Extract text-based features."""
|
| 203 |
+
|
| 204 |
+
INTENSIFIERS = {"အရမ်း", "များစွာ", "ပါး", "သိပ်", "အလွန်"}
|
| 205 |
+
POLITE_WORDS = {"ကျေးဇူး", "�心病", "ဂုဏ်", "အား", "ကြိုးစား", "ပင်ပန်း"}
|
| 206 |
+
|
| 207 |
+
def extract_from_text(self, text: str) -> TextFeatures:
|
| 208 |
+
"""Extract features from text."""
|
| 209 |
+
words = text.split()
|
| 210 |
+
|
| 211 |
+
has_intensifier = any(
|
| 212 |
+
word in self.INTENSIFIERS for word in words
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
politeness = self._estimate_politeness(text)
|
| 216 |
+
formality = self._estimate_formality(text)
|
| 217 |
+
|
| 218 |
+
return TextFeatures(
|
| 219 |
+
text_length=len(text),
|
| 220 |
+
word_count=len(words),
|
| 221 |
+
contains_intensifier=has_intensifier,
|
| 222 |
+
politeness_level=politeness,
|
| 223 |
+
formality=formality,
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
def _estimate_politeness(self, text: str) -> int:
|
| 227 |
+
"""Estimate politeness level (1-5)."""
|
| 228 |
+
score = 3 # default neutral
|
| 229 |
+
polite_count = sum(1 for w in self.POLITE_WORDS if w in text)
|
| 230 |
+
if "ပါ" in text or "ပါး" in text:
|
| 231 |
+
score += 1
|
| 232 |
+
if "ကျေးဇူး" in text:
|
| 233 |
+
score += 1
|
| 234 |
+
if polite_count > 2:
|
| 235 |
+
score += 1
|
| 236 |
+
return min(5, max(1, score))
|
| 237 |
+
|
| 238 |
+
def _estimate_formality(self, text: str) -> float:
|
| 239 |
+
"""Estimate formality (0-1)."""
|
| 240 |
+
formal_markers = {"မှ", "သည်", "ကို", "ဖြင့်", "အား"}
|
| 241 |
+
informal_markers = {"နော်", "ဟုတ်", "မဟုတ်", "လား"}
|
| 242 |
+
|
| 243 |
+
formal_count = sum(1 for m in formal_markers if m in text)
|
| 244 |
+
informal_count = sum(1 for m in informal_markers if m in text)
|
| 245 |
+
|
| 246 |
+
if formal_count + informal_count == 0:
|
| 247 |
+
return 0.5
|
| 248 |
+
|
| 249 |
+
return formal_count / (formal_count + informal_count + 1)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class MultiModalFusion(nn.Module):
|
| 253 |
+
"""Fuse audio and text modalities."""
|
| 254 |
+
|
| 255 |
+
def __init__(
|
| 256 |
+
self,
|
| 257 |
+
prosody_dim: int = 8,
|
| 258 |
+
text_dim: int = 5,
|
| 259 |
+
hidden_dim: int = 64,
|
| 260 |
+
num_classes: int = 4,
|
| 261 |
+
):
|
| 262 |
+
super().__init__()
|
| 263 |
+
|
| 264 |
+
self.prosody_encoder = nn.Sequential(
|
| 265 |
+
nn.Linear(prosody_dim, hidden_dim),
|
| 266 |
+
nn.ReLU(),
|
| 267 |
+
nn.Dropout(0.2),
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
self.text_encoder = nn.Sequential(
|
| 271 |
+
nn.Linear(text_dim, hidden_dim),
|
| 272 |
+
nn.ReLU(),
|
| 273 |
+
nn.Dropout(0.2),
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
self.fusion = nn.Sequential(
|
| 277 |
+
nn.Linear(hidden_dim * 2, hidden_dim),
|
| 278 |
+
nn.ReLU(),
|
| 279 |
+
nn.Dropout(0.3),
|
| 280 |
+
nn.Linear(hidden_dim, num_classes),
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
def forward(self, prosody: Tensor, text: Tensor) -> Tensor:
|
| 284 |
+
"""Forward pass."""
|
| 285 |
+
p_encoded = self.prosody_encoder(prosody)
|
| 286 |
+
t_encoded = self.text_encoder(text)
|
| 287 |
+
|
| 288 |
+
fused = torch.cat([p_encoded, t_encoded], dim=-1)
|
| 289 |
+
logits = self.fusion(fused)
|
| 290 |
+
|
| 291 |
+
return logits
|
| 292 |
+
|
| 293 |
+
def predict(self, prosody: Tensor, text: Tensor) -> Tuple[Tensor, Tensor]:
|
| 294 |
+
"""Predict sentiment with probabilities."""
|
| 295 |
+
logits = self.forward(prosody, text)
|
| 296 |
+
probs = torch.softmax(logits, dim=-1)
|
| 297 |
+
return logits, probs
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
class SentimentClassifier:
|
| 301 |
+
"""High-level classifier for multi-modal sentiment."""
|
| 302 |
+
|
| 303 |
+
def __init__(self, model: MultiModalFusion):
|
| 304 |
+
self.model = model
|
| 305 |
+
self.prosody_extractor = ProsodyExtractor()
|
| 306 |
+
self.text_extractor = TextFeatureExtractor()
|
| 307 |
+
|
| 308 |
+
def classify(
|
| 309 |
+
self,
|
| 310 |
+
audio: np.ndarray,
|
| 311 |
+
text: str,
|
| 312 |
+
return_probs: bool = True,
|
| 313 |
+
) -> Dict[str, Any]:
|
| 314 |
+
"""Classify sentiment from audio and text."""
|
| 315 |
+
prosody_features = self.prosody_extractor.extract_from_audio(audio)
|
| 316 |
+
prosody_hint = self.prosody_extractor.infer_sentiment(prosody_features)
|
| 317 |
+
|
| 318 |
+
text_features = self.text_extractor.extract_from_text(text)
|
| 319 |
+
|
| 320 |
+
fused = FusedFeatures(
|
| 321 |
+
prosody=prosody_features,
|
| 322 |
+
text=text_features,
|
| 323 |
+
sentiment_hint=prosody_hint,
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
prosody_tensor = fused.prosody.to_tensor().unsqueeze(0)
|
| 327 |
+
text_tensor = fused.text.to_tensor().unsqueeze(0)
|
| 328 |
+
|
| 329 |
+
with torch.no_grad():
|
| 330 |
+
logits, probs = self.model.predict(prosody_tensor, text_tensor)
|
| 331 |
+
|
| 332 |
+
result = {
|
| 333 |
+
"predicted_class": SentimentClass(probs.argmax().item()).value,
|
| 334 |
+
"prosody_hint": prosody_hint.value,
|
| 335 |
+
"text_features": text_features.to_dict(),
|
| 336 |
+
"prosody_features": prosody_features.to_dict(),
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
if return_probs:
|
| 340 |
+
result["probabilities"] = {
|
| 341 |
+
c.value: probs[0, i].item()
|
| 342 |
+
for i, c in enumerate(SentimentClass)
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
return result
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def create_fusion_model(
|
| 349 |
+
prosody_dim: int = 8,
|
| 350 |
+
text_dim: int = 5,
|
| 351 |
+
hidden_dim: int = 64,
|
| 352 |
+
num_classes: int = 4,
|
| 353 |
+
) -> MultiModalFusion:
|
| 354 |
+
"""Factory function to create fusion model."""
|
| 355 |
+
return MultiModalFusion(
|
| 356 |
+
prosody_dim=prosody_dim,
|
| 357 |
+
text_dim=text_dim,
|
| 358 |
+
hidden_dim=hidden_dim,
|
| 359 |
+
num_classes=num_classes,
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
if __name__ == "__main__":
|
| 364 |
+
# Example usage
|
| 365 |
+
model = create_fusion_model()
|
| 366 |
+
prosody = torch.randn(1, 8)
|
| 367 |
+
text = torch.randn(1, 5)
|
| 368 |
+
|
| 369 |
+
logits, probs = model.predict(prosody, text)
|
| 370 |
+
print(f"Predicted class: {SentimentClass(probs.argmax().item()).value}")
|
| 371 |
+
print(f"Probabilities: {probs}")
|
data_processing/text_normalizer.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Text normalization module for Myanmar language."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import unicodedata
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Dict, List, Optional
|
| 7 |
+
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import yaml
|
| 10 |
+
|
| 11 |
+
logger = __import__("loguru").logger
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class MyanmarTextNormalizer:
|
| 15 |
+
"""Normalize Myanmar (Burmese) text for consistent processing."""
|
| 16 |
+
|
| 17 |
+
# Myanmar Unicode ranges
|
| 18 |
+
MYANMAR_CHARS = re.compile(
|
| 19 |
+
r"[\u1000-\u100F\u1010-\u101F\u1020-\u102A\u102C-\u1030\u1031\u1032\u1036-\u1038\u1039\u103A]"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
# Normalization rules
|
| 23 |
+
NORMALIZATION_RULES = {
|
| 24 |
+
# Zero-width characters
|
| 25 |
+
"\u200B": "", # Zero-width space
|
| 26 |
+
"\u200C": "", # Zero-width non-joiner
|
| 27 |
+
"\u200D": "", # Zero-width joiner
|
| 28 |
+
"\u2060": "", # Word joiner
|
| 29 |
+
# Myanmar-specific normalizations
|
| 30 |
+
"\u1031\u103B": "\u103B\u1031", # medial order
|
| 31 |
+
"\u103D\u103E": "\u103E\u103D", # stack order
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
def __init__(self, custom_rules_path: Optional[str] = None):
|
| 35 |
+
self.custom_rules = {}
|
| 36 |
+
if custom_rules_path and Path(custom_rules_path).exists():
|
| 37 |
+
with open(custom_rules_path, "r", encoding="utf-8") as f:
|
| 38 |
+
self.custom_rules = yaml.safe_load(f) or {}
|
| 39 |
+
|
| 40 |
+
self.rules = {**self.NORMALIZATION_RULES, **self.custom_rules}
|
| 41 |
+
|
| 42 |
+
def normalize_unicode(self, text: str) -> str:
|
| 43 |
+
"""Standardize Unicode representation (NFC normalization)."""
|
| 44 |
+
return unicodedata.normalize("NFC", text)
|
| 45 |
+
|
| 46 |
+
def remove_diacritics(self, text: str) -> str:
|
| 47 |
+
"""Remove tone marks for simplified processing."""
|
| 48 |
+
diacritics = re.compile(
|
| 49 |
+
r"[\u102B-\u102D\u102F-\u1032\u1034\u1036\u1037\u1039]"
|
| 50 |
+
)
|
| 51 |
+
return diacritics.sub("", text)
|
| 52 |
+
|
| 53 |
+
def remove_whitespace(self, text: str) -> str:
|
| 54 |
+
"""Remove excessive whitespace."""
|
| 55 |
+
text = re.sub(r"\s+", " ", text)
|
| 56 |
+
return text.strip()
|
| 57 |
+
|
| 58 |
+
def normalize_punctuation(self, text: str) -> str:
|
| 59 |
+
"""Standardize punctuation marks."""
|
| 60 |
+
replacements = {
|
| 61 |
+
"၊": "။", # Myanmar comma to full stop
|
| 62 |
+
"„": '"',
|
| 63 |
+
"‟": '"',
|
| 64 |
+
"'": "'",
|
| 65 |
+
"`": "'",
|
| 66 |
+
"—": "–",
|
| 67 |
+
"–": "-",
|
| 68 |
+
}
|
| 69 |
+
for old, new in replacements.items():
|
| 70 |
+
text = text.replace(old, new)
|
| 71 |
+
return text
|
| 72 |
+
|
| 73 |
+
def apply_custom_rules(self, text: str) -> str:
|
| 74 |
+
"""Apply user-defined normalization rules."""
|
| 75 |
+
for pattern, replacement in self.rules.items():
|
| 76 |
+
text = text.replace(pattern, replacement)
|
| 77 |
+
return text
|
| 78 |
+
|
| 79 |
+
def expand_abbreviations(self, text: str, abbreviations: Dict[str, str] = None) -> str:
|
| 80 |
+
"""Expand common abbreviations."""
|
| 81 |
+
if abbreviations is None:
|
| 82 |
+
abbreviations = {
|
| 83 |
+
"အ.ပ.ခ": "အငြိမ်းစားပြည်ထဲရေးဝန်ကြီး",
|
| 84 |
+
"ဒ.ပ.လ": "ဒုတိယသမ္မတ",
|
| 85 |
+
"ပ.ရ.မှူး": "ပြည်သူ့လွှတ်တော်ဥက္ကဋ္ဌ",
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
for abbr, full in abbreviations.items():
|
| 89 |
+
text = re.sub(rf"\b{re.escape(abbr)}\b", full, text)
|
| 90 |
+
|
| 91 |
+
return text
|
| 92 |
+
|
| 93 |
+
def normalize_numbers(self, text: str) -> str:
|
| 94 |
+
"""Convert Myanmar numerals to Arabic (0-9)."""
|
| 95 |
+
myanmar_digits = "၀၁၂၃၄၅၆၇၈၉"
|
| 96 |
+
arabic_digits = "0123456789"
|
| 97 |
+
|
| 98 |
+
trans_table = str.maketrans(
|
| 99 |
+
{myanmar_digits[i]: arabic_digits[i] for i in range(10)}
|
| 100 |
+
)
|
| 101 |
+
return text.translate(trans_table)
|
| 102 |
+
|
| 103 |
+
def filter_non_myanmar(self, text: str, keep_english: bool = True) -> str:
|
| 104 |
+
"""Remove or keep non-Myanmar characters."""
|
| 105 |
+
if keep_english:
|
| 106 |
+
pattern = r"[^\u1000-\u109F\u0020-\u007E\u00A0-\u00FF]"
|
| 107 |
+
else:
|
| 108 |
+
pattern = r"[^\u1000-\u109F\s]"
|
| 109 |
+
|
| 110 |
+
return re.sub(pattern, "", text)
|
| 111 |
+
|
| 112 |
+
def normalize_line(self, text: str) -> str:
|
| 113 |
+
"""Apply all normalization steps to a single line."""
|
| 114 |
+
text = self.normalize_unicode(text)
|
| 115 |
+
text = self.apply_custom_rules(text)
|
| 116 |
+
text = self.remove_whitespace(text)
|
| 117 |
+
text = self.normalize_punctuation(text)
|
| 118 |
+
return text
|
| 119 |
+
|
| 120 |
+
def normalize_corpus(
|
| 121 |
+
self,
|
| 122 |
+
texts: List[str],
|
| 123 |
+
remove_non_myanmar: bool = False,
|
| 124 |
+
) -> List[str]:
|
| 125 |
+
"""Normalize a list of texts."""
|
| 126 |
+
normalized = []
|
| 127 |
+
for text in texts:
|
| 128 |
+
text = self.normalize_line(text)
|
| 129 |
+
if remove_non_myanmar:
|
| 130 |
+
text = self.filter_non_myanmar(text, keep_english=False)
|
| 131 |
+
normalized.append(text)
|
| 132 |
+
|
| 133 |
+
logger.info(f"Normalized {len(normalized)} texts")
|
| 134 |
+
return normalized
|
| 135 |
+
|
| 136 |
+
def normalize_dataset(
|
| 137 |
+
self,
|
| 138 |
+
input_path: str,
|
| 139 |
+
output_path: str,
|
| 140 |
+
text_column: str = "text",
|
| 141 |
+
) -> pd.DataFrame:
|
| 142 |
+
"""Normalize a dataset and save to file."""
|
| 143 |
+
df = pd.read_csv(input_path)
|
| 144 |
+
|
| 145 |
+
if text_column not in df.columns:
|
| 146 |
+
raise ValueError(f"Column '{text_column}' not found in dataset")
|
| 147 |
+
|
| 148 |
+
df[f"{text_column}_normalized"] = self.normalize_corpus(
|
| 149 |
+
df[text_column].tolist()
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
df.to_csv(output_path, index=False)
|
| 153 |
+
logger.info(f"Normalized dataset saved to {output_path}")
|
| 154 |
+
|
| 155 |
+
return df
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class ProsodyNormalizer:
|
| 159 |
+
"""Normalize prosodic features for consistent representation."""
|
| 160 |
+
|
| 161 |
+
def normalize_pitch(self, pitch_values: List[float]) -> List[float]:
|
| 162 |
+
"""Normalize pitch values to semitones from mean."""
|
| 163 |
+
import numpy as np
|
| 164 |
+
pitch_arr = np.array(pitch_values)
|
| 165 |
+
mean_pitch = np.mean(pitch_arr[pitch_arr > 0])
|
| 166 |
+
if mean_pitch == 0:
|
| 167 |
+
return pitch_values
|
| 168 |
+
semitones = 12 * np.log2(pitch_arr / mean_pitch)
|
| 169 |
+
return semitones.tolist()
|
| 170 |
+
|
| 171 |
+
def normalize_energy(self, energy_values: List[float]) -> List[float]:
|
| 172 |
+
"""Normalize energy values to 0-1 range."""
|
| 173 |
+
import numpy as np
|
| 174 |
+
energy_arr = np.array(energy_values)
|
| 175 |
+
min_e, max_e = energy_arr.min(), energy_arr.max()
|
| 176 |
+
if max_e - min_e == 0:
|
| 177 |
+
return [0.5] * len(energy_values)
|
| 178 |
+
return ((energy_arr - min_e) / (max_e - min_e)).tolist()
|
| 179 |
+
|
| 180 |
+
def quantize_prosody(
|
| 181 |
+
self,
|
| 182 |
+
prosody: dict,
|
| 183 |
+
num_levels: int = 5,
|
| 184 |
+
) -> dict:
|
| 185 |
+
"""Quantize prosodic features for categorical representation."""
|
| 186 |
+
quantized = {}
|
| 187 |
+
for key, value in prosody.items():
|
| 188 |
+
if isinstance(value, (int, float)) and key != "pitch_range":
|
| 189 |
+
normalized = max(0, min(1, (value + 100) / 200))
|
| 190 |
+
quantized[key] = int(normalized * (num_levels - 1))
|
| 191 |
+
else:
|
| 192 |
+
quantized[key] = value
|
| 193 |
+
return quantized
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def create_normalizer(config: dict = None) -> MyanmarTextNormalizer:
|
| 197 |
+
"""Factory function to create normalizer from config."""
|
| 198 |
+
if config is None:
|
| 199 |
+
config = {}
|
| 200 |
+
|
| 201 |
+
return MyanmarTextNormalizer(
|
| 202 |
+
custom_rules_path=config.get("custom_rules_path")
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
if __name__ == "__main__":
|
| 207 |
+
normalizer = create_normalizer()
|
| 208 |
+
test_text = " မင်္ဂလာပါ ၊ ကျေးဇူးပါ ပါ သည် "
|
| 209 |
+
print(f"Original: {test_text}")
|
| 210 |
+
print(f"Normalized: {normalizer.normalize_line(test_text)}")
|
federated/__init__.py
ADDED
|
File without changes
|
federated/aggregator.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Weight aggregation algorithms for Federated Learning.
|
| 2 |
+
|
| 3 |
+
Implements various aggregation methods beyond simple FedAvg:
|
| 4 |
+
- FedProx (proximal term)
|
| 5 |
+
- SCAFFOLD (variance reduction)
|
| 6 |
+
- FedOpt (adaptive optimization)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from abc import ABC, abstractmethod
|
| 10 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Aggregator(ABC):
|
| 17 |
+
"""Base class for federated aggregation algorithms."""
|
| 18 |
+
|
| 19 |
+
@abstractmethod
|
| 20 |
+
def aggregate(
|
| 21 |
+
self,
|
| 22 |
+
parameters: List[List[np.ndarray]],
|
| 23 |
+
weights: List[float],
|
| 24 |
+
client_metrics: List[Dict],
|
| 25 |
+
) -> List[np.ndarray]:
|
| 26 |
+
"""Aggregate client parameters.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
parameters: List of client parameters
|
| 30 |
+
weights: Weight for each client (usually proportional to data size)
|
| 31 |
+
client_metrics: Metrics from each client
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
Aggregated parameters
|
| 35 |
+
"""
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class FedAvgAggregator(Aggregator):
|
| 40 |
+
"""Federated Averaging (FedAvg) - standard weighted average."""
|
| 41 |
+
|
| 42 |
+
def aggregate(
|
| 43 |
+
self,
|
| 44 |
+
parameters: List[List[np.ndarray]],
|
| 45 |
+
weights: List[float],
|
| 46 |
+
client_metrics: List[Dict],
|
| 47 |
+
) -> List[np.ndarray]:
|
| 48 |
+
"""Weighted average of client parameters."""
|
| 49 |
+
if not parameters:
|
| 50 |
+
raise ValueError("No parameters to aggregate")
|
| 51 |
+
|
| 52 |
+
if len(parameters) == 1:
|
| 53 |
+
return parameters[0]
|
| 54 |
+
|
| 55 |
+
# Normalize weights
|
| 56 |
+
total_weight = sum(weights)
|
| 57 |
+
normalized_weights = [w / total_weight for w in weights]
|
| 58 |
+
|
| 59 |
+
# Weighted average
|
| 60 |
+
aggregated = None
|
| 61 |
+
|
| 62 |
+
for params, weight in zip(parameters, normalized_weights):
|
| 63 |
+
if aggregated is None:
|
| 64 |
+
aggregated = [p * weight for p in params]
|
| 65 |
+
else:
|
| 66 |
+
aggregated = [
|
| 67 |
+
a + p * weight
|
| 68 |
+
for a, p in zip(aggregated, params)
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
return aggregated
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class FedProxAggregator(Aggregator):
|
| 75 |
+
"""FedProx with proximal term for handling heterogeneity."""
|
| 76 |
+
|
| 77 |
+
def __init__(self, mu: float = 0.01):
|
| 78 |
+
"""
|
| 79 |
+
Args:
|
| 80 |
+
mu: Proximal term coefficient
|
| 81 |
+
"""
|
| 82 |
+
self.mu = mu
|
| 83 |
+
|
| 84 |
+
def aggregate(
|
| 85 |
+
self,
|
| 86 |
+
parameters: List[List[np.ndarray]],
|
| 87 |
+
weights: List[float],
|
| 88 |
+
client_metrics: List[Dict],
|
| 89 |
+
global_parameters: Optional[List[np.ndarray]] = None,
|
| 90 |
+
) -> List[np.ndarray]:
|
| 91 |
+
"""Aggregate with proximal term regularization."""
|
| 92 |
+
if global_parameters is None:
|
| 93 |
+
# Fall back to FedAvg if no global params
|
| 94 |
+
return FedAvgAggregator().aggregate(parameters, weights, client_metrics)
|
| 95 |
+
|
| 96 |
+
# Weighted average with proximal correction
|
| 97 |
+
total_weight = sum(weights)
|
| 98 |
+
normalized_weights = [w / total_weight for w in weights]
|
| 99 |
+
|
| 100 |
+
aggregated = None
|
| 101 |
+
|
| 102 |
+
for params, weight, global_params in zip(
|
| 103 |
+
parameters, normalized_weights, global_parameters
|
| 104 |
+
):
|
| 105 |
+
# Proximal term: add regularization toward global model
|
| 106 |
+
corrected_params = [
|
| 107 |
+
p + self.mu * (g - p)
|
| 108 |
+
for p, g in zip(params, global_params)
|
| 109 |
+
]
|
| 110 |
+
|
| 111 |
+
if aggregated is None:
|
| 112 |
+
aggregated = [p * weight for p in corrected_params]
|
| 113 |
+
else:
|
| 114 |
+
aggregated = [
|
| 115 |
+
a + p * weight
|
| 116 |
+
for a, p in zip(aggregated, corrected_params)
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
return aggregated
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class SCAFFOLDAggregator(Aggregator):
|
| 123 |
+
"""SCAFFOLD: Stochastic Controlled Averaging for Federated Learning."""
|
| 124 |
+
|
| 125 |
+
def __init__(self, global_control: Optional[List[np.ndarray]] = None):
|
| 126 |
+
self.global_control = global_control or None
|
| 127 |
+
|
| 128 |
+
def set_global_control(self, control: List[np.ndarray]) -> None:
|
| 129 |
+
"""Set global control variates."""
|
| 130 |
+
self.global_control = control
|
| 131 |
+
|
| 132 |
+
def aggregate(
|
| 133 |
+
self,
|
| 134 |
+
parameters: List[List[np.ndarray]],
|
| 135 |
+
weights: List[float],
|
| 136 |
+
client_metrics: List[Dict],
|
| 137 |
+
client_controls: Optional[List[List[np.ndarray]]] = None,
|
| 138 |
+
) -> List[np.ndarray]:
|
| 139 |
+
"""Aggregate using SCAFFOLD algorithm."""
|
| 140 |
+
if client_controls is None or self.global_control is None:
|
| 141 |
+
return FedAvgAggregator().aggregate(parameters, weights, client_metrics)
|
| 142 |
+
|
| 143 |
+
total_weight = sum(weights)
|
| 144 |
+
normalized_weights = [w / total_weight for w in weights]
|
| 145 |
+
|
| 146 |
+
# Compute weight updates (gradient-like terms)
|
| 147 |
+
aggregated = None
|
| 148 |
+
|
| 149 |
+
for params, weight, client_ctrl, global_ctrl in zip(
|
| 150 |
+
parameters, normalized_weights, client_controls, self.global_control
|
| 151 |
+
):
|
| 152 |
+
# Direction: client_params - global_params + global_control - client_control
|
| 153 |
+
delta = [
|
| 154 |
+
p - g + gc - cc
|
| 155 |
+
for p, g, gc, cc in zip(params, parameters[0], self.global_control, client_ctrl)
|
| 156 |
+
]
|
| 157 |
+
|
| 158 |
+
if aggregated is None:
|
| 159 |
+
aggregated = [d * weight for d in delta]
|
| 160 |
+
else:
|
| 161 |
+
aggregated = [
|
| 162 |
+
a + d * weight
|
| 163 |
+
for a, d in zip(aggregated, delta)
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
# Add back global parameters
|
| 167 |
+
if aggregated:
|
| 168 |
+
aggregated = [
|
| 169 |
+
g + self.mu * a if hasattr(self, 'mu') else g + 0.001 * a
|
| 170 |
+
for g, a in zip(self.global_control if self.global_control else parameters[0], aggregated)
|
| 171 |
+
]
|
| 172 |
+
|
| 173 |
+
return aggregated
|
| 174 |
+
|
| 175 |
+
@property
|
| 176 |
+
def mu(self) -> float:
|
| 177 |
+
"""Learning rate for SCAFFOLD."""
|
| 178 |
+
return 0.001
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class FedOptAggregator(Aggregator):
|
| 182 |
+
"""FedOpt: Adaptive Federated Optimization using server-side optimizer."""
|
| 183 |
+
|
| 184 |
+
def __init__(
|
| 185 |
+
self,
|
| 186 |
+
server_lr: float = 1.0,
|
| 187 |
+
beta_1: float = 0.9,
|
| 188 |
+
beta_2: float = 0.99,
|
| 189 |
+
epsilon: float = 1e-4,
|
| 190 |
+
):
|
| 191 |
+
self.server_lr = server_lr
|
| 192 |
+
self.beta_1 = beta_1
|
| 193 |
+
self.beta_2 = beta_2
|
| 194 |
+
self.epsilon = epsilon
|
| 195 |
+
|
| 196 |
+
self.m_t: Optional[List[np.ndarray]] = None
|
| 197 |
+
self.v_t: Optional[List[np.ndarray]] = None
|
| 198 |
+
self.t = 0
|
| 199 |
+
|
| 200 |
+
def aggregate(
|
| 201 |
+
self,
|
| 202 |
+
parameters: List[List[np.ndarray]],
|
| 203 |
+
weights: List[float],
|
| 204 |
+
client_metrics: List[Dict],
|
| 205 |
+
) -> List[np.ndarray]:
|
| 206 |
+
"""Aggregate using FedOpt (server-side Adam)."""
|
| 207 |
+
if not parameters:
|
| 208 |
+
raise ValueError("No parameters to aggregate")
|
| 209 |
+
|
| 210 |
+
# FedAvg as base
|
| 211 |
+
base_aggregate = FedAvgAggregator().aggregate(parameters, weights, client_metrics)
|
| 212 |
+
|
| 213 |
+
# Compute delta from base to weighted average
|
| 214 |
+
if self.m_t is None:
|
| 215 |
+
self.m_t = [np.zeros_like(p) for p in base_aggregate]
|
| 216 |
+
self.v_t = [np.zeros_like(p) for p in base_aggregate]
|
| 217 |
+
|
| 218 |
+
delta = [
|
| 219 |
+
ba - p0
|
| 220 |
+
for ba, p0 in zip(base_aggregate, parameters[0])
|
| 221 |
+
]
|
| 222 |
+
|
| 223 |
+
self.t += 1
|
| 224 |
+
|
| 225 |
+
# Update momentum and second moment
|
| 226 |
+
self.m_t = [
|
| 227 |
+
self.beta_1 * m + (1 - self.beta_1) * d
|
| 228 |
+
for m, d in zip(self.m_t, delta)
|
| 229 |
+
]
|
| 230 |
+
|
| 231 |
+
self.v_t = [
|
| 232 |
+
self.beta_2 * v + (1 - self.beta_2) * (d ** 2)
|
| 233 |
+
for v, d in zip(self.v_t, delta)
|
| 234 |
+
]
|
| 235 |
+
|
| 236 |
+
# Bias correction
|
| 237 |
+
m_hat = [m / (1 - self.beta_1 ** self.t) for m in self.m_t]
|
| 238 |
+
v_hat = [v / (1 - self.beta_2 ** self.t) for v in self.v_t]
|
| 239 |
+
|
| 240 |
+
# Apply update
|
| 241 |
+
aggregated = [
|
| 242 |
+
p0 + self.server_lr * m / (np.sqrt(v) + self.epsilon)
|
| 243 |
+
for p0, m, v in zip(parameters[0], m_hat, v_hat)
|
| 244 |
+
]
|
| 245 |
+
|
| 246 |
+
return aggregated
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def create_aggregator(
|
| 250 |
+
method: str = "fedavg",
|
| 251 |
+
**kwargs,
|
| 252 |
+
) -> Aggregator:
|
| 253 |
+
"""Factory function to create aggregator.
|
| 254 |
+
|
| 255 |
+
Args:
|
| 256 |
+
method: Aggregation method ("fedavg", "fedprox", "scaffold", "fedopt")
|
| 257 |
+
**kwargs: Additional arguments for the aggregator
|
| 258 |
+
|
| 259 |
+
Returns:
|
| 260 |
+
Aggregator instance
|
| 261 |
+
"""
|
| 262 |
+
if method == "fedavg":
|
| 263 |
+
return FedAvgAggregator()
|
| 264 |
+
elif method == "fedprox":
|
| 265 |
+
return FedProxAggregator(mu=kwargs.get("mu", 0.01))
|
| 266 |
+
elif method == "scaffold":
|
| 267 |
+
return SCAFFOLDAggregator()
|
| 268 |
+
elif method == "fedopt":
|
| 269 |
+
return FedOptAggregator(
|
| 270 |
+
server_lr=kwargs.get("server_lr", 1.0),
|
| 271 |
+
beta_1=kwargs.get("beta_1", 0.9),
|
| 272 |
+
beta_2=kwargs.get("beta_2", 0.99),
|
| 273 |
+
)
|
| 274 |
+
else:
|
| 275 |
+
raise ValueError(f"Unknown aggregation method: {method}")
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
if __name__ == "__main__":
|
| 279 |
+
print("Available aggregators: fedavg, fedprox, scaffold, fedopt")
|
| 280 |
+
|
| 281 |
+
# Example usage
|
| 282 |
+
agg = create_aggregator("fedavg")
|
| 283 |
+
print(f"Created aggregator: {type(agg).__name__}")
|
federated/client.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Federated Learning Client for Myanmar Ghost project.
|
| 2 |
+
|
| 3 |
+
Enables training on distributed data (e.g., hospitals, restaurants)
|
| 4 |
+
without centralizing sensitive data.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import flwr as fl
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
import torch.nn as nn
|
| 11 |
+
import torch.optim as optim
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 14 |
+
import yaml
|
| 15 |
+
|
| 16 |
+
logger = __import__("loguru").logger
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class FederatedClient(fl.client.NumPyClient):
|
| 20 |
+
"""Flower client for federated learning."""
|
| 21 |
+
|
| 22 |
+
def __init__(
|
| 23 |
+
self,
|
| 24 |
+
model: nn.Module,
|
| 25 |
+
trainloader,
|
| 26 |
+
valloader,
|
| 27 |
+
device: str = "cuda" if torch.cuda.is_available() else "cpu",
|
| 28 |
+
client_id: str = "client_1",
|
| 29 |
+
output_dir: str = "outputs/federated",
|
| 30 |
+
):
|
| 31 |
+
self.model = model.to(device)
|
| 32 |
+
self.trainloader = trainloader
|
| 33 |
+
self.valloader = valloader
|
| 34 |
+
self.device = device
|
| 35 |
+
self.client_id = client_id
|
| 36 |
+
self.output_dir = Path(output_dir)
|
| 37 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 38 |
+
|
| 39 |
+
self.criterion = nn.CrossEntropyLoss()
|
| 40 |
+
self.optimizer = optim.AdamW(self.model.parameters(), lr=1e-4)
|
| 41 |
+
|
| 42 |
+
def get_parameters(self) -> List[np.ndarray]:
|
| 43 |
+
"""Get model parameters as numpy arrays."""
|
| 44 |
+
return [val.cpu().numpy() for _, val in self.model.state_dict().items()]
|
| 45 |
+
|
| 46 |
+
def set_parameters(self, parameters: List[np.ndarray]) -> None:
|
| 47 |
+
"""Set model parameters from numpy arrays."""
|
| 48 |
+
state_dict = dict(self.model.state_dict())
|
| 49 |
+
for i, (key, _) in enumerate(state_dict.items()):
|
| 50 |
+
state_dict[key] = torch.from_numpy(parameters[i])
|
| 51 |
+
self.model.load_state_dict(state_dict)
|
| 52 |
+
|
| 53 |
+
def fit(
|
| 54 |
+
self,
|
| 55 |
+
parameters: List[np.ndarray],
|
| 56 |
+
config: Dict[str, Any],
|
| 57 |
+
) -> Tuple[List[np.ndarray], int, Dict]:
|
| 58 |
+
"""Train model on local data.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
parameters: Global model parameters
|
| 62 |
+
config: Training configuration
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
Updated parameters, number of samples, metrics
|
| 66 |
+
"""
|
| 67 |
+
# Set global parameters
|
| 68 |
+
self.set_parameters(parameters)
|
| 69 |
+
|
| 70 |
+
# Training configuration
|
| 71 |
+
epochs = config.get("local_epochs", 1)
|
| 72 |
+
batch_size = config.get("batch_size", 32)
|
| 73 |
+
learning_rate = config.get("learning_rate", 1e-4)
|
| 74 |
+
|
| 75 |
+
self.optimizer = optim.AdamW(
|
| 76 |
+
self.model.parameters(),
|
| 77 |
+
lr=learning_rate,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Local training
|
| 81 |
+
self.model.train()
|
| 82 |
+
total_loss = 0.0
|
| 83 |
+
total_samples = 0
|
| 84 |
+
|
| 85 |
+
for epoch in range(epochs):
|
| 86 |
+
epoch_loss = 0.0
|
| 87 |
+
epoch_samples = 0
|
| 88 |
+
|
| 89 |
+
for batch_idx, (inputs, labels) in enumerate(self.trainloader):
|
| 90 |
+
inputs = inputs.to(self.device)
|
| 91 |
+
labels = labels.to(self.device)
|
| 92 |
+
|
| 93 |
+
self.optimizer.zero_grad()
|
| 94 |
+
outputs = self.model(inputs)
|
| 95 |
+
loss = self.criterion(outputs, labels)
|
| 96 |
+
loss.backward()
|
| 97 |
+
self.optimizer.step()
|
| 98 |
+
|
| 99 |
+
epoch_loss += loss.item() * inputs.size(0)
|
| 100 |
+
epoch_samples += inputs.size(0)
|
| 101 |
+
|
| 102 |
+
total_loss += epoch_loss
|
| 103 |
+
total_samples += epoch_samples
|
| 104 |
+
|
| 105 |
+
logger.info(
|
| 106 |
+
f"Client {self.client_id} - Epoch {epoch+1}/{epochs}: "
|
| 107 |
+
f"Loss={epoch_loss/epoch_samples:.4f}"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Save checkpoint
|
| 111 |
+
self._save_checkpoint(epochs)
|
| 112 |
+
|
| 113 |
+
metrics = {
|
| 114 |
+
"loss": total_loss / total_samples,
|
| 115 |
+
"samples": total_samples,
|
| 116 |
+
"epochs": epochs,
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
return self.get_parameters(), total_samples, metrics
|
| 120 |
+
|
| 121 |
+
def evaluate(
|
| 122 |
+
self,
|
| 123 |
+
parameters: List[np.ndarray],
|
| 124 |
+
config: Dict[str, Any],
|
| 125 |
+
) -> Tuple[float, int, Dict]:
|
| 126 |
+
"""Evaluate model on local validation data.
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
parameters: Model parameters
|
| 130 |
+
config: Evaluation configuration
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
Loss, number of samples, metrics
|
| 134 |
+
"""
|
| 135 |
+
self.set_parameters(parameters)
|
| 136 |
+
|
| 137 |
+
self.model.eval()
|
| 138 |
+
total_loss = 0.0
|
| 139 |
+
total_correct = 0
|
| 140 |
+
total_samples = 0
|
| 141 |
+
|
| 142 |
+
with torch.no_grad():
|
| 143 |
+
for inputs, labels in self.valloader:
|
| 144 |
+
inputs = inputs.to(self.device)
|
| 145 |
+
labels = labels.to(self.device)
|
| 146 |
+
|
| 147 |
+
outputs = self.model(inputs)
|
| 148 |
+
loss = self.criterion(outputs, labels)
|
| 149 |
+
|
| 150 |
+
total_loss += loss.item() * inputs.size(0)
|
| 151 |
+
_, predicted = outputs.max(1)
|
| 152 |
+
total_correct += predicted.eq(labels).sum().item()
|
| 153 |
+
total_samples += inputs.size(0)
|
| 154 |
+
|
| 155 |
+
accuracy = total_correct / total_samples if total_samples > 0 else 0.0
|
| 156 |
+
|
| 157 |
+
logger.info(
|
| 158 |
+
f"Client {self.client_id} - Evaluation: "
|
| 159 |
+
f"Loss={total_loss/total_samples:.4f}, Accuracy={accuracy:.4f}"
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
metrics = {
|
| 163 |
+
"loss": total_loss / total_samples,
|
| 164 |
+
"accuracy": accuracy,
|
| 165 |
+
"samples": total_samples,
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
return total_loss / total_samples, total_samples, metrics
|
| 169 |
+
|
| 170 |
+
def _save_checkpoint(self, epochs: int) -> None:
|
| 171 |
+
"""Save model checkpoint."""
|
| 172 |
+
path = self.output_dir / f"{self.client_id}_checkpoint.pt"
|
| 173 |
+
torch.save({
|
| 174 |
+
"model_state_dict": self.model.state_dict(),
|
| 175 |
+
"optimizer_state_dict": self.optimizer.state_dict(),
|
| 176 |
+
"epochs": epochs,
|
| 177 |
+
"client_id": self.client_id,
|
| 178 |
+
}, path)
|
| 179 |
+
logger.info(f"Checkpoint saved to {path}")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def load_client_config(config_path: str) -> Dict:
|
| 183 |
+
"""Load client configuration from YAML."""
|
| 184 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 185 |
+
return yaml.safe_load(f)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class ClientFactory:
|
| 189 |
+
"""Factory for creating federated clients."""
|
| 190 |
+
|
| 191 |
+
def __init__(
|
| 192 |
+
self,
|
| 193 |
+
model_fn,
|
| 194 |
+
data_dir: str,
|
| 195 |
+
output_dir: str = "outputs/federated",
|
| 196 |
+
):
|
| 197 |
+
self.model_fn = model_fn
|
| 198 |
+
self.data_dir = Path(data_dir)
|
| 199 |
+
self.output_dir = Path(output_dir)
|
| 200 |
+
|
| 201 |
+
def create_client(
|
| 202 |
+
self,
|
| 203 |
+
client_id: str,
|
| 204 |
+
config: Dict,
|
| 205 |
+
) -> FederatedClient:
|
| 206 |
+
"""Create a client for the given configuration."""
|
| 207 |
+
from torch.utils.data import DataLoader
|
| 208 |
+
|
| 209 |
+
# Load data
|
| 210 |
+
train_data = self._load_partition(
|
| 211 |
+
client_id,
|
| 212 |
+
config.get("train_file", f"{client_id}_train.pt"),
|
| 213 |
+
)
|
| 214 |
+
val_data = self._load_partition(
|
| 215 |
+
client_id,
|
| 216 |
+
config.get("val_file", f"{client_id}_val.pt"),
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
trainloader = DataLoader(
|
| 220 |
+
train_data,
|
| 221 |
+
batch_size=config.get("batch_size", 32),
|
| 222 |
+
shuffle=True,
|
| 223 |
+
)
|
| 224 |
+
valloader = DataLoader(
|
| 225 |
+
val_data,
|
| 226 |
+
batch_size=config.get("batch_size", 32),
|
| 227 |
+
shuffle=False,
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
model = self.model_fn()
|
| 231 |
+
|
| 232 |
+
return FederatedClient(
|
| 233 |
+
model=model,
|
| 234 |
+
trainloader=trainloader,
|
| 235 |
+
valloader=valloader,
|
| 236 |
+
device=config.get("device", "cuda"),
|
| 237 |
+
client_id=client_id,
|
| 238 |
+
output_dir=str(self.output_dir),
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
def _load_partition(self, client_id: str, filename: str):
|
| 242 |
+
"""Load data partition for a client."""
|
| 243 |
+
path = self.data_dir / client_id / filename
|
| 244 |
+
if path.exists():
|
| 245 |
+
return torch.load(path)
|
| 246 |
+
raise FileNotFoundError(f"Data partition not found: {path}")
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def start_client(
|
| 250 |
+
model_fn,
|
| 251 |
+
data_dir: str,
|
| 252 |
+
client_id: str,
|
| 253 |
+
server_address: str = "localhost:8080",
|
| 254 |
+
config_path: Optional[str] = None,
|
| 255 |
+
) -> None:
|
| 256 |
+
"""Start a federated learning client."""
|
| 257 |
+
config = {}
|
| 258 |
+
if config_path:
|
| 259 |
+
config = load_client_config(config_path)
|
| 260 |
+
|
| 261 |
+
factory = ClientFactory(model_fn, data_dir)
|
| 262 |
+
client = factory.create_client(client_id, config)
|
| 263 |
+
|
| 264 |
+
app = fl.client.start_numpy_client(
|
| 265 |
+
server_address=server_address,
|
| 266 |
+
client=client,
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
if __name__ == "__main__":
|
| 271 |
+
print("FederatedClient module loaded")
|
| 272 |
+
print("Use start_client() to start a federated learning client")
|
federated/server.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Federated Learning Server for Myanmar Ghost project.
|
| 2 |
+
|
| 3 |
+
Coordinates model training across distributed clients
|
| 4 |
+
using Flower framework.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import flwr as fl
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
import torch.nn as nn
|
| 11 |
+
import yaml
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 14 |
+
|
| 15 |
+
logger = __import__("loguru").logger
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class FederatedServer:
|
| 19 |
+
"""Flower server for federated learning."""
|
| 20 |
+
|
| 21 |
+
def __init__(
|
| 22 |
+
self,
|
| 23 |
+
model: nn.Module,
|
| 24 |
+
strategy: Optional[fl.server.strategy.Strategy] = None,
|
| 25 |
+
output_dir: str = "outputs/federated",
|
| 26 |
+
):
|
| 27 |
+
self.model = model
|
| 28 |
+
self.strategy = strategy or self._default_strategy()
|
| 29 |
+
self.output_dir = Path(output_dir)
|
| 30 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 31 |
+
|
| 32 |
+
self.server = None
|
| 33 |
+
self.history = None
|
| 34 |
+
|
| 35 |
+
def _default_strategy(self) -> fl.server.strategy.Strategy:
|
| 36 |
+
"""Create default federated averaging strategy."""
|
| 37 |
+
return fl.server.strategy.FedAvg(
|
| 38 |
+
fraction_fit=0.5,
|
| 39 |
+
fraction_evaluate=0.5,
|
| 40 |
+
min_fit_clients=2,
|
| 41 |
+
min_evaluate_clients=2,
|
| 42 |
+
min_available_clients=2,
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
def get_model_parameters(self) -> List[np.ndarray]:
|
| 46 |
+
"""Get current model parameters."""
|
| 47 |
+
return [val.cpu().numpy() for _, val in self.model.state_dict().items()]
|
| 48 |
+
|
| 49 |
+
def set_model_parameters(self, parameters: List[np.ndarray]) -> None:
|
| 50 |
+
"""Set model parameters from numpy arrays."""
|
| 51 |
+
state_dict = dict(self.model.state_dict())
|
| 52 |
+
for i, (key, _) in enumerate(state_dict.items()):
|
| 53 |
+
state_dict[key] = torch.from_numpy(parameters[i])
|
| 54 |
+
self.model.load_state_dict(state_dict)
|
| 55 |
+
|
| 56 |
+
def aggregate_results(
|
| 57 |
+
self,
|
| 58 |
+
results: List[Tuple[List[np.ndarray], int, Dict]],
|
| 59 |
+
) -> Tuple[List[np.ndarray], Dict]:
|
| 60 |
+
"""Aggregate client results using weighted averaging.
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
results: List of (parameters, num_samples, metrics)
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
Aggregated parameters, aggregated metrics
|
| 67 |
+
"""
|
| 68 |
+
total_samples = sum(r[1] for r in results)
|
| 69 |
+
|
| 70 |
+
# Weighted average of parameters
|
| 71 |
+
weighted_params = None
|
| 72 |
+
|
| 73 |
+
for params, n_samples, _ in results:
|
| 74 |
+
weight = n_samples / total_samples
|
| 75 |
+
if weighted_params is None:
|
| 76 |
+
weighted_params = [p * weight for p in params]
|
| 77 |
+
else:
|
| 78 |
+
weighted_params = [
|
| 79 |
+
wp + p * weight
|
| 80 |
+
for wp, p in zip(weighted_params, params)
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
# Aggregate metrics
|
| 84 |
+
aggregated_metrics = {}
|
| 85 |
+
for _, _, metrics in results:
|
| 86 |
+
for key, value in metrics.items():
|
| 87 |
+
if key not in aggregated_metrics:
|
| 88 |
+
aggregated_metrics[key] = []
|
| 89 |
+
aggregated_metrics[key].append(value)
|
| 90 |
+
|
| 91 |
+
# Average metrics
|
| 92 |
+
avg_metrics = {
|
| 93 |
+
key: np.mean(values)
|
| 94 |
+
for key, values in aggregated_metrics.items()
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
logger.info(
|
| 98 |
+
f"Aggregated {len(results)} client results "
|
| 99 |
+
f"(total samples: {total_samples})"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
return weighted_params, avg_metrics
|
| 103 |
+
|
| 104 |
+
def save_global_model(self, path: Optional[str] = None) -> str:
|
| 105 |
+
"""Save the global model."""
|
| 106 |
+
if path is None:
|
| 107 |
+
from datetime import datetime
|
| 108 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 109 |
+
path = self.output_dir / f"global_model_{timestamp}.pt"
|
| 110 |
+
|
| 111 |
+
torch.save({
|
| 112 |
+
"model_state_dict": self.model.state_dict(),
|
| 113 |
+
}, path)
|
| 114 |
+
|
| 115 |
+
logger.info(f"Global model saved to {path}")
|
| 116 |
+
return str(path)
|
| 117 |
+
|
| 118 |
+
def load_global_model(self, path: str) -> None:
|
| 119 |
+
"""Load a global model checkpoint."""
|
| 120 |
+
checkpoint = torch.load(path)
|
| 121 |
+
self.model.load_state_dict(checkpoint["model_state_dict"])
|
| 122 |
+
logger.info(f"Global model loaded from {path}")
|
| 123 |
+
|
| 124 |
+
def start(
|
| 125 |
+
self,
|
| 126 |
+
server_address: str = "[::]:8080",
|
| 127 |
+
num_rounds: int = 5,
|
| 128 |
+
) -> fl.server.Server:
|
| 129 |
+
"""Start the federated learning server.
|
| 130 |
+
|
| 131 |
+
Args:
|
| 132 |
+
server_address: Address to bind the server
|
| 133 |
+
num_rounds: Number of federated rounds
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
Server instance
|
| 137 |
+
"""
|
| 138 |
+
from flwr.server import ServerConfig
|
| 139 |
+
|
| 140 |
+
config = ServerConfig(num_rounds=num_rounds)
|
| 141 |
+
|
| 142 |
+
def on_fit_config_fn(rnd: int) -> Dict[str, Any]:
|
| 143 |
+
"""Generate config for each round."""
|
| 144 |
+
return {
|
| 145 |
+
"local_epochs": 1,
|
| 146 |
+
"batch_size": 32,
|
| 147 |
+
"learning_rate": 1e-4,
|
| 148 |
+
"round": rnd,
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
def on_evaluate_config_fn(rnd: int) -> Dict[str, Any]:
|
| 152 |
+
"""Generate config for evaluation."""
|
| 153 |
+
return {
|
| 154 |
+
"batch_size": 32,
|
| 155 |
+
"round": rnd,
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
# Create strategy with callbacks
|
| 159 |
+
strategy = fl.server.strategy.FedAvg(
|
| 160 |
+
fraction_fit=0.5,
|
| 161 |
+
fraction_evaluate=0.5,
|
| 162 |
+
min_fit_clients=2,
|
| 163 |
+
min_evaluate_clients=2,
|
| 164 |
+
min_available_clients=2,
|
| 165 |
+
on_fit_config_fn=on_fit_config_fn,
|
| 166 |
+
on_evaluate_config_fn=on_evaluate_config_fn,
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
self.server = fl.server.start_server(
|
| 170 |
+
server_address=server_address,
|
| 171 |
+
server=config,
|
| 172 |
+
strategy=strategy,
|
| 173 |
+
model=self.model,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
return self.server
|
| 177 |
+
|
| 178 |
+
def get_history(self) -> Optional[List[Dict]]:
|
| 179 |
+
"""Get training history from server."""
|
| 180 |
+
if self.server and hasattr(self.server, "history"):
|
| 181 |
+
return self.server.history
|
| 182 |
+
return None
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def load_server_config(config_path: str) -> Dict:
|
| 186 |
+
"""Load server configuration from YAML."""
|
| 187 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 188 |
+
return yaml.safe_load(f)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def create_server(
|
| 192 |
+
model: nn.Module,
|
| 193 |
+
strategy: Optional[str] = "fedavg",
|
| 194 |
+
output_dir: str = "outputs/federated",
|
| 195 |
+
) -> FederatedServer:
|
| 196 |
+
"""Factory function to create federated server."""
|
| 197 |
+
return FederatedServer(
|
| 198 |
+
model=model,
|
| 199 |
+
output_dir=output_dir,
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
if __name__ == "__main__":
|
| 204 |
+
print("FederatedServer module loaded")
|
| 205 |
+
print("Use create_server() to create a server and start() to begin")
|
models/__init__.py
ADDED
|
File without changes
|
models/base_model.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Base model class for Myanmar Ghost project."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from abc import ABC, abstractmethod
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn as nn
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class BaseModel(ABC, nn.Module):
|
| 15 |
+
"""Abstract base class for all models."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, config: Optional[Dict] = None):
|
| 18 |
+
super().__init__()
|
| 19 |
+
self.config = config or {}
|
| 20 |
+
self.device = torch.device(
|
| 21 |
+
"cuda" if torch.cuda.is_available() else "cpu"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
@abstractmethod
|
| 25 |
+
def forward(self, *args, **kwargs) -> torch.Tensor:
|
| 26 |
+
"""Forward pass."""
|
| 27 |
+
pass
|
| 28 |
+
|
| 29 |
+
@abstractmethod
|
| 30 |
+
def predict(self, *args, **kwargs) -> Dict[str, Any]:
|
| 31 |
+
"""Make predictions."""
|
| 32 |
+
pass
|
| 33 |
+
|
| 34 |
+
def save(self, path: str) -> None:
|
| 35 |
+
"""Save model checkpoint."""
|
| 36 |
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
torch.save({
|
| 38 |
+
"model_state_dict": self.state_dict(),
|
| 39 |
+
"config": self.config,
|
| 40 |
+
}, path)
|
| 41 |
+
logger.info(f"Model saved to {path}")
|
| 42 |
+
|
| 43 |
+
def load(self, path: str) -> None:
|
| 44 |
+
"""Load model checkpoint."""
|
| 45 |
+
checkpoint = torch.load(path, map_location=self.device)
|
| 46 |
+
self.load_state_dict(checkpoint["model_state_dict"])
|
| 47 |
+
if "config" in checkpoint:
|
| 48 |
+
self.config = checkpoint["config"]
|
| 49 |
+
logger.info(f"Model loaded from {path}")
|
| 50 |
+
|
| 51 |
+
def get_num_parameters(self) -> int:
|
| 52 |
+
"""Get total number of parameters."""
|
| 53 |
+
return sum(p.numel() for p in self.parameters())
|
| 54 |
+
|
| 55 |
+
def get_num_trainable_parameters(self) -> int:
|
| 56 |
+
"""Get number of trainable parameters."""
|
| 57 |
+
return sum(p.numel() for p in self.parameters() if p.requires_grad)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class SentimentClassifier(nn.Module):
|
| 61 |
+
"""Base sentiment classifier."""
|
| 62 |
+
|
| 63 |
+
def __init__(
|
| 64 |
+
self,
|
| 65 |
+
input_dim: int,
|
| 66 |
+
hidden_dim: int,
|
| 67 |
+
num_classes: int = 4,
|
| 68 |
+
dropout: float = 0.1,
|
| 69 |
+
):
|
| 70 |
+
super().__init__()
|
| 71 |
+
self.fc1 = nn.Linear(input_dim, hidden_dim)
|
| 72 |
+
self.dropout = nn.Dropout(dropout)
|
| 73 |
+
self.fc2 = nn.Linear(hidden_dim, hidden_dim // 2)
|
| 74 |
+
self.fc3 = nn.Linear(hidden_dim // 2, num_classes)
|
| 75 |
+
self.relu = nn.ReLU()
|
| 76 |
+
|
| 77 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 78 |
+
x = self.relu(self.fc1(x))
|
| 79 |
+
x = self.dropout(x)
|
| 80 |
+
x = self.relu(self.fc2(x))
|
| 81 |
+
x = self.dropout(x)
|
| 82 |
+
x = self.fc3(x)
|
| 83 |
+
return x
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def create_model(
|
| 87 |
+
model_type: str = "transformer",
|
| 88 |
+
**kwargs,
|
| 89 |
+
) -> BaseModel:
|
| 90 |
+
"""Factory function to create models."""
|
| 91 |
+
from .transformer_model import TransformerSentimentModel
|
| 92 |
+
from .multimodal_model import MultiModalSentimentModel
|
| 93 |
+
|
| 94 |
+
if model_type == "transformer":
|
| 95 |
+
return TransformerSentimentModel(**kwargs)
|
| 96 |
+
elif model_type == "multimodal":
|
| 97 |
+
return MultiModalSentimentModel(**kwargs)
|
| 98 |
+
elif model_type == "base":
|
| 99 |
+
return SentimentClassifier(**kwargs)
|
| 100 |
+
else:
|
| 101 |
+
raise ValueError(f"Unknown model type: {model_type}")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
if __name__ == "__main__":
|
| 105 |
+
model = SentimentClassifier(input_dim=768, hidden_dim=256, num_classes=4)
|
| 106 |
+
print(f"Model parameters: {model.get_num_parameters():,}")
|
models/evaluate.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation script for Myanmar Ghost sentiment model."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any, Dict
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn as nn
|
| 13 |
+
from torch.utils.data import DataLoader, Dataset
|
| 14 |
+
from tqdm import tqdm
|
| 15 |
+
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 17 |
+
|
| 18 |
+
from src.utils.logger import setup_logger
|
| 19 |
+
from src.utils.metrics import compute_metrics, compute_confusion_matrix
|
| 20 |
+
|
| 21 |
+
logger = setup_logger("evaluate", log_dir="outputs/logs")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class SentimentDataset(Dataset):
|
| 25 |
+
"""Dataset for sentiment classification."""
|
| 26 |
+
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
data,
|
| 30 |
+
tokenizer,
|
| 31 |
+
max_length: int = 512,
|
| 32 |
+
label_mapping: dict = None,
|
| 33 |
+
):
|
| 34 |
+
self.data = data
|
| 35 |
+
self.tokenizer = tokenizer
|
| 36 |
+
self.max_length = max_length
|
| 37 |
+
self.label_mapping = label_mapping or {
|
| 38 |
+
"negative": 0, "neutral": 1, "positive": 2, "sarcastic": 3
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
def __len__(self) -> int:
|
| 42 |
+
return len(self.data)
|
| 43 |
+
|
| 44 |
+
def __getitem__(self, idx: int):
|
| 45 |
+
item = self.data[idx]
|
| 46 |
+
|
| 47 |
+
encoding = self.tokenizer(
|
| 48 |
+
item["text"],
|
| 49 |
+
truncation=True,
|
| 50 |
+
max_length=self.max_length,
|
| 51 |
+
padding="max_length",
|
| 52 |
+
return_tensors="pt",
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
label = self.label_mapping.get(item.get("label", "neutral"), 1)
|
| 56 |
+
|
| 57 |
+
return (
|
| 58 |
+
encoding["input_ids"].squeeze(0),
|
| 59 |
+
encoding["attention_mask"].squeeze(0),
|
| 60 |
+
torch.tensor(label, dtype=torch.long),
|
| 61 |
+
item.get("text", ""),
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def load_data(data_path: str):
|
| 66 |
+
"""Load evaluation data."""
|
| 67 |
+
if data_path.endswith(".jsonl"):
|
| 68 |
+
data = []
|
| 69 |
+
with open(data_path, "r", encoding="utf-8") as f:
|
| 70 |
+
for line in f:
|
| 71 |
+
if line.strip():
|
| 72 |
+
data.append(json.loads(line))
|
| 73 |
+
elif data_path.endswith(".json"):
|
| 74 |
+
with open(data_path, "r", encoding="utf-8") as f:
|
| 75 |
+
data = json.load(f)
|
| 76 |
+
else:
|
| 77 |
+
raise ValueError(f"Unsupported format: {data_path}")
|
| 78 |
+
|
| 79 |
+
return data
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def evaluate(
|
| 83 |
+
model: nn.Module,
|
| 84 |
+
dataloader: DataLoader,
|
| 85 |
+
device: torch.device,
|
| 86 |
+
class_names: list = None,
|
| 87 |
+
) -> Dict[str, Any]:
|
| 88 |
+
"""Evaluate the model."""
|
| 89 |
+
if class_names is None:
|
| 90 |
+
class_names = ["negative", "neutral", "positive", "sarcastic"]
|
| 91 |
+
|
| 92 |
+
model.eval()
|
| 93 |
+
|
| 94 |
+
all_predictions = []
|
| 95 |
+
all_labels = []
|
| 96 |
+
all_texts = []
|
| 97 |
+
all_probabilities = []
|
| 98 |
+
|
| 99 |
+
with torch.no_grad():
|
| 100 |
+
for input_ids, attention_mask, labels, texts in tqdm(dataloader, desc="Evaluating"):
|
| 101 |
+
input_ids = input_ids.to(device)
|
| 102 |
+
attention_mask = attention_mask.to(device)
|
| 103 |
+
|
| 104 |
+
outputs = model(input_ids, attention_mask)
|
| 105 |
+
probs = torch.softmax(outputs, dim=-1)
|
| 106 |
+
|
| 107 |
+
predictions = outputs.argmax(dim=-1).cpu().tolist()
|
| 108 |
+
all_predictions.extend(predictions)
|
| 109 |
+
all_labels.extend(labels.tolist())
|
| 110 |
+
all_texts.extend(texts)
|
| 111 |
+
all_probabilities.extend(probs.cpu().numpy().tolist())
|
| 112 |
+
|
| 113 |
+
# Compute metrics
|
| 114 |
+
metrics = compute_metrics(all_predictions, all_labels, class_names)
|
| 115 |
+
|
| 116 |
+
# Confusion matrix
|
| 117 |
+
cm = compute_confusion_matrix(all_predictions, all_labels)
|
| 118 |
+
|
| 119 |
+
# Per-sample results
|
| 120 |
+
results = []
|
| 121 |
+
for i, (text, label, pred, probs) in enumerate(zip(
|
| 122 |
+
all_texts, all_labels, all_predictions, all_probabilities
|
| 123 |
+
)):
|
| 124 |
+
results.append({
|
| 125 |
+
"text": text,
|
| 126 |
+
"true_label": class_names[label],
|
| 127 |
+
"predicted_label": class_names[pred],
|
| 128 |
+
"correct": label == pred,
|
| 129 |
+
"probabilities": {
|
| 130 |
+
class_names[j]: probs[j] for j in range(len(class_names))
|
| 131 |
+
},
|
| 132 |
+
})
|
| 133 |
+
|
| 134 |
+
return {
|
| 135 |
+
"metrics": metrics,
|
| 136 |
+
"confusion_matrix": cm.tolist(),
|
| 137 |
+
"class_names": class_names,
|
| 138 |
+
"results": results,
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def save_results(results: Dict, output_path: str) -> None:
|
| 143 |
+
"""Save evaluation results to file."""
|
| 144 |
+
output_dir = Path(output_path).parent
|
| 145 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 146 |
+
|
| 147 |
+
# Save full results
|
| 148 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 149 |
+
json.dump(results, f, indent=2, ensure_ascii=False)
|
| 150 |
+
|
| 151 |
+
# Save summary
|
| 152 |
+
summary_path = output_dir / "evaluation_summary.txt"
|
| 153 |
+
with open(summary_path, "w", encoding="utf-8") as f:
|
| 154 |
+
f.write("=" * 60 + "\n")
|
| 155 |
+
f.write("EVALUATION SUMMARY\n")
|
| 156 |
+
f.write("=" * 60 + "\n\n")
|
| 157 |
+
|
| 158 |
+
metrics = results["metrics"]
|
| 159 |
+
f.write(f"Accuracy: {metrics['accuracy']:.4f}\n")
|
| 160 |
+
f.write(f"F1 (weighted): {metrics['f1_weighted']:.4f}\n")
|
| 161 |
+
f.write(f"F1 (macro): {metrics['f1_macro']:.4f}\n")
|
| 162 |
+
f.write(f"Precision: {metrics['precision_weighted']:.4f}\n")
|
| 163 |
+
f.write(f"Recall: {metrics['recall_weighted']:.4f}\n\n")
|
| 164 |
+
|
| 165 |
+
f.write("Per-class F1:\n")
|
| 166 |
+
for name in results["class_names"]:
|
| 167 |
+
f1_key = f"f1_{name}"
|
| 168 |
+
if f1_key in metrics:
|
| 169 |
+
f.write(f" {name}: {metrics[f1_key]:.4f}\n")
|
| 170 |
+
|
| 171 |
+
f.write(f"\nConfusion Matrix:\n")
|
| 172 |
+
f.write(str(np.array(results["confusion_matrix"])) + "\n")
|
| 173 |
+
|
| 174 |
+
logger.info(f"Results saved to {output_path}")
|
| 175 |
+
logger.info(f"Summary saved to {summary_path}")
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def main(args):
|
| 179 |
+
"""Main evaluation function."""
|
| 180 |
+
logger.info("Starting evaluation...")
|
| 181 |
+
logger.info(f"Arguments: {vars(args)}")
|
| 182 |
+
|
| 183 |
+
device = torch.device("cuda" if torch.cuda.is_available() and not args.cpu else "cpu")
|
| 184 |
+
logger.info(f"Using device: {device}")
|
| 185 |
+
|
| 186 |
+
# Load tokenizer
|
| 187 |
+
from transformers import AutoTokenizer
|
| 188 |
+
tokenizer = AutoTokenizer.from_pretrained(args.model_name)
|
| 189 |
+
|
| 190 |
+
# Load data
|
| 191 |
+
logger.info(f"Loading data from {args.data_path}")
|
| 192 |
+
data = load_data(args.data_path)
|
| 193 |
+
logger.info(f"Total samples: {len(data)}")
|
| 194 |
+
|
| 195 |
+
# Create dataset and dataloader
|
| 196 |
+
dataset = SentimentDataset(data, tokenizer, args.max_length)
|
| 197 |
+
dataloader = DataLoader(
|
| 198 |
+
dataset,
|
| 199 |
+
batch_size=args.batch_size,
|
| 200 |
+
shuffle=False,
|
| 201 |
+
num_workers=2,
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
# Load model
|
| 205 |
+
logger.info(f"Loading model from {args.model_path}")
|
| 206 |
+
from src.models.transformer_model import TransformerSentimentModel
|
| 207 |
+
|
| 208 |
+
model = TransformerSentimentModel(
|
| 209 |
+
model_name=args.model_name,
|
| 210 |
+
num_labels=4,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
checkpoint = torch.load(args.model_path, map_location=device)
|
| 214 |
+
if "model_state_dict" in checkpoint:
|
| 215 |
+
model.load_state_dict(checkpoint["model_state_dict"])
|
| 216 |
+
else:
|
| 217 |
+
model.load_state_dict(checkpoint)
|
| 218 |
+
|
| 219 |
+
model.to(device)
|
| 220 |
+
|
| 221 |
+
# Evaluate
|
| 222 |
+
results = evaluate(model, dataloader, device)
|
| 223 |
+
|
| 224 |
+
# Print summary
|
| 225 |
+
logger.info("\n" + "=" * 60)
|
| 226 |
+
logger.info("EVALUATION RESULTS")
|
| 227 |
+
logger.info("=" * 60)
|
| 228 |
+
logger.info(f"Accuracy: {results['metrics']['accuracy']:.4f}")
|
| 229 |
+
logger.info(f"F1 (weighted): {results['metrics']['f1_weighted']:.4f}")
|
| 230 |
+
logger.info(f"Precision: {results['metrics']['precision_weighted']:.4f}")
|
| 231 |
+
logger.info(f"Recall: {results['metrics']['recall_weighted']:.4f}")
|
| 232 |
+
|
| 233 |
+
# Save results
|
| 234 |
+
output_path = args.output_path or "outputs/results/evaluation_results.json"
|
| 235 |
+
save_results(results, output_path)
|
| 236 |
+
|
| 237 |
+
return results["metrics"]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
if __name__ == "__main__":
|
| 241 |
+
parser = argparse.ArgumentParser(description="Evaluate Myanmar Ghost model")
|
| 242 |
+
|
| 243 |
+
parser.add_argument("--data_path", type=str, required=True, help="Test data file")
|
| 244 |
+
parser.add_argument("--model_path", type=str, required=True, help="Model checkpoint")
|
| 245 |
+
parser.add_argument("--model_name", type=str, default="bert-base-multilingual-cased")
|
| 246 |
+
parser.add_argument("--output_path", type=str, default=None, help="Output path")
|
| 247 |
+
parser.add_argument("--batch_size", type=int, default=32)
|
| 248 |
+
parser.add_argument("--max_length", type=int, default=512)
|
| 249 |
+
parser.add_argument("--cpu", action="store_true", help="Use CPU only")
|
| 250 |
+
|
| 251 |
+
args = parser.parse_args()
|
| 252 |
+
main(args)
|
models/multimodal_model.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-modal sentiment model combining audio and text."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Any, Dict, Optional
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn as nn
|
| 8 |
+
|
| 9 |
+
from .base_model import BaseModel
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class MultiModalSentimentModel(BaseModel):
|
| 15 |
+
"""Multi-modal model combining text and audio features."""
|
| 16 |
+
|
| 17 |
+
def __init__(
|
| 18 |
+
self,
|
| 19 |
+
text_dim: int = 768,
|
| 20 |
+
audio_dim: int = 8,
|
| 21 |
+
hidden_dim: int = 256,
|
| 22 |
+
num_classes: int = 4,
|
| 23 |
+
dropout: float = 0.2,
|
| 24 |
+
):
|
| 25 |
+
"""
|
| 26 |
+
Args:
|
| 27 |
+
text_dim: Text embedding dimension
|
| 28 |
+
audio_dim: Audio feature dimension
|
| 29 |
+
hidden_dim: Hidden layer dimension
|
| 30 |
+
num_classes: Number of sentiment classes
|
| 31 |
+
dropout: Dropout rate
|
| 32 |
+
"""
|
| 33 |
+
super().__init__()
|
| 34 |
+
|
| 35 |
+
self.text_dim = text_dim
|
| 36 |
+
self.audio_dim = audio_dim
|
| 37 |
+
|
| 38 |
+
# Text encoder
|
| 39 |
+
self.text_encoder = nn.Sequential(
|
| 40 |
+
nn.Linear(text_dim, hidden_dim),
|
| 41 |
+
nn.ReLU(),
|
| 42 |
+
nn.Dropout(dropout),
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# Audio encoder
|
| 46 |
+
self.audio_encoder = nn.Sequential(
|
| 47 |
+
nn.Linear(audio_dim, hidden_dim // 2),
|
| 48 |
+
nn.ReLU(),
|
| 49 |
+
nn.Dropout(dropout),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Fusion layer
|
| 53 |
+
self.fusion = nn.Sequential(
|
| 54 |
+
nn.Linear(hidden_dim + hidden_dim // 2, hidden_dim),
|
| 55 |
+
nn.ReLU(),
|
| 56 |
+
nn.Dropout(dropout),
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Classification head
|
| 60 |
+
self.classifier = nn.Sequential(
|
| 61 |
+
nn.Linear(hidden_dim, hidden_dim // 2),
|
| 62 |
+
nn.ReLU(),
|
| 63 |
+
nn.Dropout(dropout),
|
| 64 |
+
nn.Linear(hidden_dim // 2, num_classes),
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
def forward(
|
| 68 |
+
self,
|
| 69 |
+
text_features: torch.Tensor,
|
| 70 |
+
audio_features: torch.Tensor,
|
| 71 |
+
) -> torch.Tensor:
|
| 72 |
+
"""
|
| 73 |
+
Forward pass.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
text_features: Text embeddings (batch, text_dim)
|
| 77 |
+
audio_features: Audio features (batch, audio_dim)
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
Logits (batch, num_classes)
|
| 81 |
+
"""
|
| 82 |
+
# Encode each modality
|
| 83 |
+
text_encoded = self.text_encoder(text_features)
|
| 84 |
+
audio_encoded = self.audio_encoder(audio_features)
|
| 85 |
+
|
| 86 |
+
# Concatenate and fuse
|
| 87 |
+
fused = torch.cat([text_encoded, audio_encoded], dim=-1)
|
| 88 |
+
fused = self.fusion(fused)
|
| 89 |
+
|
| 90 |
+
# Classify
|
| 91 |
+
logits = self.classifier(fused)
|
| 92 |
+
|
| 93 |
+
return logits
|
| 94 |
+
|
| 95 |
+
def predict(
|
| 96 |
+
self,
|
| 97 |
+
text_features: torch.Tensor,
|
| 98 |
+
audio_features: Optional[torch.Tensor] = None,
|
| 99 |
+
) -> Dict[str, Any]:
|
| 100 |
+
"""Make predictions."""
|
| 101 |
+
self.eval()
|
| 102 |
+
|
| 103 |
+
if audio_features is None:
|
| 104 |
+
# Text-only mode
|
| 105 |
+
audio_features = torch.zeros(
|
| 106 |
+
text_features.size(0), self.audio_dim
|
| 107 |
+
).to(text_features.device)
|
| 108 |
+
|
| 109 |
+
with torch.no_grad():
|
| 110 |
+
logits = self.forward(text_features, audio_features)
|
| 111 |
+
probs = torch.softmax(logits, dim=-1)
|
| 112 |
+
|
| 113 |
+
sentiment_labels = ["negative", "neutral", "positive", "sarcastic"]
|
| 114 |
+
|
| 115 |
+
predictions = []
|
| 116 |
+
for i, probs_i in enumerate(probs):
|
| 117 |
+
pred_idx = probs_i.argmax().item()
|
| 118 |
+
predictions.append({
|
| 119 |
+
"sentiment": sentiment_labels[pred_idx],
|
| 120 |
+
"confidence": probs_i[pred_idx].item(),
|
| 121 |
+
"probabilities": {
|
| 122 |
+
label: probs_i[j].item()
|
| 123 |
+
for j, label in enumerate(sentiment_labels)
|
| 124 |
+
},
|
| 125 |
+
})
|
| 126 |
+
|
| 127 |
+
return {"predictions": predictions}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class CrossModalAttention(nn.Module):
|
| 131 |
+
"""Cross-modal attention for audio-text fusion."""
|
| 132 |
+
|
| 133 |
+
def __init__(
|
| 134 |
+
self,
|
| 135 |
+
query_dim: int,
|
| 136 |
+
key_dim: int,
|
| 137 |
+
hidden_dim: int,
|
| 138 |
+
num_heads: int = 4,
|
| 139 |
+
):
|
| 140 |
+
super().__init__()
|
| 141 |
+
|
| 142 |
+
self.num_heads = num_heads
|
| 143 |
+
self.head_dim = hidden_dim // num_heads
|
| 144 |
+
|
| 145 |
+
self.query = nn.Linear(query_dim, hidden_dim)
|
| 146 |
+
self.key = nn.Linear(key_dim, hidden_dim)
|
| 147 |
+
self.value = nn.Linear(key_dim, hidden_dim)
|
| 148 |
+
|
| 149 |
+
self.output = nn.Linear(hidden_dim, hidden_dim)
|
| 150 |
+
self.scale = self.head_dim ** -0.5
|
| 151 |
+
|
| 152 |
+
def forward(
|
| 153 |
+
self,
|
| 154 |
+
query: torch.Tensor,
|
| 155 |
+
key_value: torch.Tensor,
|
| 156 |
+
) -> torch.Tensor:
|
| 157 |
+
"""Cross-attention forward pass."""
|
| 158 |
+
batch_size = query.size(0)
|
| 159 |
+
|
| 160 |
+
# Linear projections
|
| 161 |
+
Q = self.query(query).view(batch_size, -1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 162 |
+
K = self.key(key_value).view(batch_size, -1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 163 |
+
V = self.value(key_value).view(batch_size, -1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 164 |
+
|
| 165 |
+
# Attention scores
|
| 166 |
+
scores = torch.matmul(Q, K.transpose(-2, -1)) * self.scale
|
| 167 |
+
attention = torch.softmax(scores, dim=-1)
|
| 168 |
+
|
| 169 |
+
# Apply attention to values
|
| 170 |
+
context = torch.matmul(attention, V)
|
| 171 |
+
context = context.transpose(1, 2).contiguous().view(batch_size, -1, self.num_heads * self.head_dim)
|
| 172 |
+
|
| 173 |
+
return self.output(context)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
if __name__ == "__main__":
|
| 177 |
+
print("Testing MultiModalSentimentModel...")
|
| 178 |
+
|
| 179 |
+
model = MultiModalSentimentModel(
|
| 180 |
+
text_dim=768,
|
| 181 |
+
audio_dim=8,
|
| 182 |
+
hidden_dim=256,
|
| 183 |
+
num_classes=4,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# Mock inputs
|
| 187 |
+
text_features = torch.randn(2, 768)
|
| 188 |
+
audio_features = torch.randn(2, 8)
|
| 189 |
+
|
| 190 |
+
logits = model(text_features, audio_features)
|
| 191 |
+
print(f"Output shape: {logits.shape}")
|
| 192 |
+
print(f"Total parameters: {model.get_num_parameters():,}")
|
models/train.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training script for Myanmar Ghost sentiment model."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import logging
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
import torch.nn as nn
|
| 11 |
+
import torch.optim as optim
|
| 12 |
+
from torch.utils.data import DataLoader, Dataset
|
| 13 |
+
from tqdm import tqdm
|
| 14 |
+
|
| 15 |
+
# Add parent directory to path
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 17 |
+
|
| 18 |
+
from src.utils.logger import setup_logger
|
| 19 |
+
from src.utils.metrics import compute_metrics, MetricsTracker
|
| 20 |
+
|
| 21 |
+
logger = setup_logger("train", log_dir="outputs/logs")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class SentimentDataset(Dataset):
|
| 25 |
+
"""Dataset for sentiment classification."""
|
| 26 |
+
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
data: List[Dict],
|
| 30 |
+
tokenizer,
|
| 31 |
+
max_length: int = 512,
|
| 32 |
+
label_mapping: Dict[str, int] = None,
|
| 33 |
+
):
|
| 34 |
+
self.data = data
|
| 35 |
+
self.tokenizer = tokenizer
|
| 36 |
+
self.max_length = max_length
|
| 37 |
+
self.label_mapping = label_mapping or {
|
| 38 |
+
"negative": 0,
|
| 39 |
+
"neutral": 1,
|
| 40 |
+
"positive": 2,
|
| 41 |
+
"sarcastic": 3,
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
def __len__(self) -> int:
|
| 45 |
+
return len(self.data)
|
| 46 |
+
|
| 47 |
+
def __getitem__(self, idx: int) -> tuple:
|
| 48 |
+
item = self.data[idx]
|
| 49 |
+
|
| 50 |
+
encoding = self.tokenizer(
|
| 51 |
+
item["text"],
|
| 52 |
+
truncation=True,
|
| 53 |
+
max_length=self.max_length,
|
| 54 |
+
padding="max_length",
|
| 55 |
+
return_tensors="pt",
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
label = self.label_mapping.get(item.get("label", "neutral"), 1)
|
| 59 |
+
|
| 60 |
+
return (
|
| 61 |
+
encoding["input_ids"].squeeze(0),
|
| 62 |
+
encoding["attention_mask"].squeeze(0),
|
| 63 |
+
torch.tensor(label, dtype=torch.long),
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def train_epoch(
|
| 68 |
+
model: nn.Module,
|
| 69 |
+
dataloader: DataLoader,
|
| 70 |
+
criterion: nn.Module,
|
| 71 |
+
optimizer: optim.Optimizer,
|
| 72 |
+
device: torch.device,
|
| 73 |
+
scheduler: Optional[Any] = None,
|
| 74 |
+
) -> Dict[str, float]:
|
| 75 |
+
"""Train for one epoch."""
|
| 76 |
+
model.train()
|
| 77 |
+
|
| 78 |
+
total_loss = 0.0
|
| 79 |
+
all_predictions = []
|
| 80 |
+
all_labels = []
|
| 81 |
+
|
| 82 |
+
progress_bar = tqdm(dataloader, desc="Training")
|
| 83 |
+
|
| 84 |
+
for batch_idx, (input_ids, attention_mask, labels) in enumerate(progress_bar):
|
| 85 |
+
input_ids = input_ids.to(device)
|
| 86 |
+
attention_mask = attention_mask.to(device)
|
| 87 |
+
labels = labels.to(device)
|
| 88 |
+
|
| 89 |
+
optimizer.zero_grad()
|
| 90 |
+
|
| 91 |
+
outputs = model(input_ids, attention_mask)
|
| 92 |
+
loss = criterion(outputs, labels)
|
| 93 |
+
|
| 94 |
+
loss.backward()
|
| 95 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
|
| 96 |
+
optimizer.step()
|
| 97 |
+
|
| 98 |
+
if scheduler:
|
| 99 |
+
scheduler.step()
|
| 100 |
+
|
| 101 |
+
total_loss += loss.item()
|
| 102 |
+
|
| 103 |
+
predictions = outputs.argmax(dim=-1).cpu().tolist()
|
| 104 |
+
all_predictions.extend(predictions)
|
| 105 |
+
all_labels.extend(labels.cpu().tolist())
|
| 106 |
+
|
| 107 |
+
progress_bar.set_postfix({"loss": loss.item()})
|
| 108 |
+
|
| 109 |
+
metrics = compute_metrics(all_predictions, all_labels)
|
| 110 |
+
metrics["loss"] = total_loss / len(dataloader)
|
| 111 |
+
|
| 112 |
+
return metrics
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def evaluate(
|
| 116 |
+
model: nn.Module,
|
| 117 |
+
dataloader: DataLoader,
|
| 118 |
+
criterion: nn.Module,
|
| 119 |
+
device: torch.device,
|
| 120 |
+
) -> Dict[str, float]:
|
| 121 |
+
"""Evaluate the model."""
|
| 122 |
+
model.eval()
|
| 123 |
+
|
| 124 |
+
total_loss = 0.0
|
| 125 |
+
all_predictions = []
|
| 126 |
+
all_labels = []
|
| 127 |
+
|
| 128 |
+
with torch.no_grad():
|
| 129 |
+
for input_ids, attention_mask, labels in tqdm(dataloader, desc="Evaluating"):
|
| 130 |
+
input_ids = input_ids.to(device)
|
| 131 |
+
attention_mask = attention_mask.to(device)
|
| 132 |
+
labels = labels.to(device)
|
| 133 |
+
|
| 134 |
+
outputs = model(input_ids, attention_mask)
|
| 135 |
+
loss = criterion(outputs, labels)
|
| 136 |
+
|
| 137 |
+
total_loss += loss.item()
|
| 138 |
+
|
| 139 |
+
predictions = outputs.argmax(dim=-1).cpu().tolist()
|
| 140 |
+
all_predictions.extend(predictions)
|
| 141 |
+
all_labels.extend(labels.cpu().tolist())
|
| 142 |
+
|
| 143 |
+
metrics = compute_metrics(all_predictions, all_labels)
|
| 144 |
+
metrics["loss"] = total_loss / len(dataloader)
|
| 145 |
+
|
| 146 |
+
return metrics
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def load_data(data_path: str) -> List[Dict]:
|
| 150 |
+
"""Load training data from JSON or JSONL file."""
|
| 151 |
+
import json
|
| 152 |
+
|
| 153 |
+
data = []
|
| 154 |
+
|
| 155 |
+
if data_path.endswith(".jsonl"):
|
| 156 |
+
with open(data_path, "r", encoding="utf-8") as f:
|
| 157 |
+
for line in f:
|
| 158 |
+
if line.strip():
|
| 159 |
+
data.append(json.loads(line))
|
| 160 |
+
elif data_path.endswith(".json"):
|
| 161 |
+
with open(data_path, "r", encoding="utf-8") as f:
|
| 162 |
+
data = json.load(f)
|
| 163 |
+
else:
|
| 164 |
+
raise ValueError(f"Unsupported file format: {data_path}")
|
| 165 |
+
|
| 166 |
+
return data
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def main(args):
|
| 170 |
+
"""Main training function."""
|
| 171 |
+
logger.info("Starting training...")
|
| 172 |
+
logger.info(f"Arguments: {vars(args)}")
|
| 173 |
+
|
| 174 |
+
# Device
|
| 175 |
+
device = torch.device(
|
| 176 |
+
"cuda" if torch.cuda.is_available() and not args.cpu else "cpu"
|
| 177 |
+
)
|
| 178 |
+
logger.info(f"Using device: {device}")
|
| 179 |
+
|
| 180 |
+
# Load tokenizer
|
| 181 |
+
logger.info(f"Loading tokenizer from {args.model_name}")
|
| 182 |
+
from transformers import AutoTokenizer
|
| 183 |
+
tokenizer = AutoTokenizer.from_pretrained(args.model_name)
|
| 184 |
+
|
| 185 |
+
# Load data
|
| 186 |
+
logger.info(f"Loading data from {args.train_data}")
|
| 187 |
+
train_data = load_data(args.train_data)
|
| 188 |
+
val_data = load_data(args.val_data) if args.val_data else []
|
| 189 |
+
|
| 190 |
+
logger.info(f"Train samples: {len(train_data)}, Val samples: {len(val_data)}")
|
| 191 |
+
|
| 192 |
+
# Create datasets
|
| 193 |
+
train_dataset = SentimentDataset(train_data, tokenizer, args.max_length)
|
| 194 |
+
train_loader = DataLoader(
|
| 195 |
+
train_dataset,
|
| 196 |
+
batch_size=args.batch_size,
|
| 197 |
+
shuffle=True,
|
| 198 |
+
num_workers=2,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
val_loader = None
|
| 202 |
+
if val_data:
|
| 203 |
+
val_dataset = SentimentDataset(val_data, tokenizer, args.max_length)
|
| 204 |
+
val_loader = DataLoader(
|
| 205 |
+
val_dataset,
|
| 206 |
+
batch_size=args.batch_size,
|
| 207 |
+
shuffle=False,
|
| 208 |
+
num_workers=2,
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
# Create model
|
| 212 |
+
logger.info("Creating model...")
|
| 213 |
+
from src.models.transformer_model import TransformerSentimentModel
|
| 214 |
+
|
| 215 |
+
model = TransformerSentimentModel(
|
| 216 |
+
model_name=args.model_name,
|
| 217 |
+
num_labels=4,
|
| 218 |
+
dropout=args.dropout,
|
| 219 |
+
freeze_encoder=args.freeze_encoder,
|
| 220 |
+
)
|
| 221 |
+
model.to(device)
|
| 222 |
+
|
| 223 |
+
logger.info(f"Model parameters: {model.get_num_parameters():,}")
|
| 224 |
+
logger.info(f"Trainable: {model.get_num_trainable_parameters():,}")
|
| 225 |
+
|
| 226 |
+
# Loss and optimizer
|
| 227 |
+
criterion = nn.CrossEntropyLoss()
|
| 228 |
+
optimizer = optim.AdamW(
|
| 229 |
+
model.parameters(),
|
| 230 |
+
lr=args.learning_rate,
|
| 231 |
+
weight_decay=args.weight_decay,
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
# Scheduler
|
| 235 |
+
total_steps = len(train_loader) * args.num_epochs
|
| 236 |
+
warmup_steps = int(total_steps * 0.1)
|
| 237 |
+
|
| 238 |
+
scheduler = optim.lr_scheduler.LinearLR(
|
| 239 |
+
optimizer,
|
| 240 |
+
start_factor=0.1,
|
| 241 |
+
total_iters=warmup_steps,
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
# Training loop
|
| 245 |
+
metrics_tracker = MetricsTracker(
|
| 246 |
+
metrics=["loss", "accuracy", "f1_weighted"],
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
best_f1 = 0.0
|
| 250 |
+
best_model_path = Path(args.output_dir) / "best_model.pt"
|
| 251 |
+
|
| 252 |
+
for epoch in range(args.num_epochs):
|
| 253 |
+
logger.info(f"\nEpoch {epoch + 1}/{args.num_epochs}")
|
| 254 |
+
|
| 255 |
+
# Train
|
| 256 |
+
train_metrics = train_epoch(
|
| 257 |
+
model, train_loader, criterion, optimizer, device, scheduler
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
logger.info(f"Train - Loss: {train_metrics['loss']:.4f}, "
|
| 261 |
+
f"Acc: {train_metrics['accuracy']:.4f}, "
|
| 262 |
+
f"F1: {train_metrics['f1_weighted']:.4f}")
|
| 263 |
+
|
| 264 |
+
# Evaluate
|
| 265 |
+
if val_loader:
|
| 266 |
+
val_metrics = evaluate(model, val_loader, criterion, device)
|
| 267 |
+
|
| 268 |
+
logger.info(f"Val - Loss: {val_metrics['loss']:.4f}, "
|
| 269 |
+
f"Acc: {val_metrics['accuracy']:.4f}, "
|
| 270 |
+
f"F1: {val_metrics['f1_weighted']:.4f}")
|
| 271 |
+
|
| 272 |
+
metrics = {"train_" + k: v for k, v in train_metrics.items()}
|
| 273 |
+
metrics.update({"val_" + k: v for k, v in val_metrics.items()})
|
| 274 |
+
else:
|
| 275 |
+
metrics = {"train_" + k: v for k, v in train_metrics.items()}
|
| 276 |
+
|
| 277 |
+
metrics_tracker.update(metrics, epoch)
|
| 278 |
+
|
| 279 |
+
# Save best model
|
| 280 |
+
current_f1 = train_metrics.get("f1_weighted", 0)
|
| 281 |
+
if current_f1 > best_f1:
|
| 282 |
+
best_f1 = current_f1
|
| 283 |
+
model.save(str(best_model_path))
|
| 284 |
+
logger.info(f"Saved best model (F1: {best_f1:.4f})")
|
| 285 |
+
|
| 286 |
+
# Save final model
|
| 287 |
+
final_path = Path(args.output_dir) / "final_model.pt"
|
| 288 |
+
model.save(str(final_path))
|
| 289 |
+
|
| 290 |
+
logger.info(f"\nTraining complete! Best F1: {best_f1:.4f}")
|
| 291 |
+
|
| 292 |
+
return best_f1
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
if __name__ == "__main__":
|
| 296 |
+
parser = argparse.ArgumentParser(description="Train Myanmar Ghost model")
|
| 297 |
+
|
| 298 |
+
# Data arguments
|
| 299 |
+
parser.add_argument("--train_data", type=str, required=True, help="Training data file")
|
| 300 |
+
parser.add_argument("--val_data", type=str, default=None, help="Validation data file")
|
| 301 |
+
parser.add_argument("--output_dir", type=str, default="outputs/models", help="Output directory")
|
| 302 |
+
|
| 303 |
+
# Model arguments
|
| 304 |
+
parser.add_argument("--model_name", type=str, default="bert-base-multilingual-cased")
|
| 305 |
+
parser.add_argument("--max_length", type=int, default=512)
|
| 306 |
+
parser.add_argument("--dropout", type=float, default=0.1)
|
| 307 |
+
parser.add_argument("--freeze_encoder", action="store_true")
|
| 308 |
+
|
| 309 |
+
# Training arguments
|
| 310 |
+
parser.add_argument("--batch_size", type=int, default=16)
|
| 311 |
+
parser.add_argument("--num_epochs", type=int, default=10)
|
| 312 |
+
parser.add_argument("--learning_rate", type=float, default=5e-5)
|
| 313 |
+
parser.add_argument("--weight_decay", type=float, default=0.01)
|
| 314 |
+
parser.add_argument("--cpu", action="store_true", help="Use CPU only")
|
| 315 |
+
|
| 316 |
+
args = parser.parse_args()
|
| 317 |
+
|
| 318 |
+
main(args)
|
models/transformer_model.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Transformer-based sentiment model for Myanmar text."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Any, Dict, Optional
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn as nn
|
| 8 |
+
from transformers import (
|
| 9 |
+
AutoConfig,
|
| 10 |
+
AutoModel,
|
| 11 |
+
AutoModelForSequenceClassification,
|
| 12 |
+
AutoTokenizer,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
from .base_model import BaseModel
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TransformerSentimentModel(BaseModel):
|
| 21 |
+
"""Transformer-based sentiment classification model."""
|
| 22 |
+
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
model_name: str = "bert-base-multilingual-cased",
|
| 26 |
+
num_labels: int = 4,
|
| 27 |
+
dropout: float = 0.1,
|
| 28 |
+
freeze_encoder: bool = False,
|
| 29 |
+
):
|
| 30 |
+
"""
|
| 31 |
+
Args:
|
| 32 |
+
model_name: Pretrained model name
|
| 33 |
+
num_labels: Number of sentiment labels
|
| 34 |
+
dropout: Dropout rate
|
| 35 |
+
freeze_encoder: Whether to freeze encoder weights
|
| 36 |
+
"""
|
| 37 |
+
super().__init__()
|
| 38 |
+
|
| 39 |
+
self.model_name = model_name
|
| 40 |
+
self.num_labels = num_labels
|
| 41 |
+
|
| 42 |
+
# Load pretrained config
|
| 43 |
+
self.config = AutoConfig.from_pretrained(model_name)
|
| 44 |
+
|
| 45 |
+
# Load pretrained model
|
| 46 |
+
self.transformer = AutoModel.from_pretrained(model_name)
|
| 47 |
+
|
| 48 |
+
# Classification head
|
| 49 |
+
self.dropout = nn.Dropout(dropout)
|
| 50 |
+
self.classifier = nn.Linear(self.config.hidden_size, num_labels)
|
| 51 |
+
|
| 52 |
+
# Freeze encoder if requested
|
| 53 |
+
if freeze_encoder:
|
| 54 |
+
for param in self.transformer.parameters():
|
| 55 |
+
param.requires_grad = False
|
| 56 |
+
|
| 57 |
+
self.to(self.device)
|
| 58 |
+
|
| 59 |
+
def forward(
|
| 60 |
+
self,
|
| 61 |
+
input_ids: torch.Tensor,
|
| 62 |
+
attention_mask: Optional[torch.Tensor] = None,
|
| 63 |
+
token_type_ids: Optional[torch.Tensor] = None,
|
| 64 |
+
) -> torch.Tensor:
|
| 65 |
+
"""Forward pass."""
|
| 66 |
+
outputs = self.transformer(
|
| 67 |
+
input_ids=input_ids,
|
| 68 |
+
attention_mask=attention_mask,
|
| 69 |
+
token_type_ids=token_type_ids,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# Use [CLS] token representation
|
| 73 |
+
pooled_output = outputs.last_hidden_state[:, 0, :]
|
| 74 |
+
pooled_output = self.dropout(pooled_output)
|
| 75 |
+
logits = self.classifier(pooled_output)
|
| 76 |
+
|
| 77 |
+
return logits
|
| 78 |
+
|
| 79 |
+
def predict(
|
| 80 |
+
self,
|
| 81 |
+
texts: list,
|
| 82 |
+
tokenizer,
|
| 83 |
+
batch_size: int = 16,
|
| 84 |
+
) -> Dict[str, Any]:
|
| 85 |
+
"""Make predictions on texts."""
|
| 86 |
+
self.eval()
|
| 87 |
+
|
| 88 |
+
all_probs = []
|
| 89 |
+
|
| 90 |
+
with torch.no_grad():
|
| 91 |
+
for i in range(0, len(texts), batch_size):
|
| 92 |
+
batch_texts = texts[i:i + batch_size]
|
| 93 |
+
|
| 94 |
+
encoding = tokenizer(
|
| 95 |
+
batch_texts,
|
| 96 |
+
padding=True,
|
| 97 |
+
truncation=True,
|
| 98 |
+
max_length=512,
|
| 99 |
+
return_tensors="pt",
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
input_ids = encoding["input_ids"].to(self.device)
|
| 103 |
+
attention_mask = encoding["attention_mask"].to(self.device)
|
| 104 |
+
|
| 105 |
+
logits = self.forward(input_ids, attention_mask)
|
| 106 |
+
probs = torch.softmax(logits, dim=-1)
|
| 107 |
+
|
| 108 |
+
all_probs.append(probs.cpu().numpy())
|
| 109 |
+
|
| 110 |
+
import numpy as np
|
| 111 |
+
all_probs = np.vstack(all_probs)
|
| 112 |
+
|
| 113 |
+
sentiment_labels = ["negative", "neutral", "positive", "sarcastic"]
|
| 114 |
+
|
| 115 |
+
predictions = []
|
| 116 |
+
for i, probs in enumerate(all_probs):
|
| 117 |
+
pred_idx = probs.argmax()
|
| 118 |
+
predictions.append({
|
| 119 |
+
"text": texts[i],
|
| 120 |
+
"sentiment": sentiment_labels[pred_idx],
|
| 121 |
+
"confidence": probs[pred_idx],
|
| 122 |
+
"probabilities": {
|
| 123 |
+
label: probs[j] for j, label in enumerate(sentiment_labels)
|
| 124 |
+
},
|
| 125 |
+
})
|
| 126 |
+
|
| 127 |
+
return {"predictions": predictions}
|
| 128 |
+
|
| 129 |
+
def extract_features(
|
| 130 |
+
self,
|
| 131 |
+
input_ids: torch.Tensor,
|
| 132 |
+
attention_mask: Optional[torch.Tensor] = None,
|
| 133 |
+
) -> torch.Tensor:
|
| 134 |
+
"""Extract hidden features."""
|
| 135 |
+
outputs = self.transformer(
|
| 136 |
+
input_ids=input_ids,
|
| 137 |
+
attention_mask=attention_mask,
|
| 138 |
+
)
|
| 139 |
+
return outputs.last_hidden_state
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def load_pretrained_model(
|
| 143 |
+
model_path: str,
|
| 144 |
+
num_labels: int = 4,
|
| 145 |
+
) -> TransformerSentimentModel:
|
| 146 |
+
"""Load a pretrained model from path or HuggingFace."""
|
| 147 |
+
# Check if it's a HuggingFace model
|
| 148 |
+
if "/" in model_path:
|
| 149 |
+
return TransformerSentimentModel(
|
| 150 |
+
model_name=model_path,
|
| 151 |
+
num_labels=num_labels,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# Load from local checkpoint
|
| 155 |
+
model = TransformerSentimentModel(num_labels=num_labels)
|
| 156 |
+
checkpoint = torch.load(model_path, map_location="cpu")
|
| 157 |
+
|
| 158 |
+
if "model_state_dict" in checkpoint:
|
| 159 |
+
model.load_state_dict(checkpoint["model_state_dict"])
|
| 160 |
+
elif "model" in checkpoint:
|
| 161 |
+
model.transformer = checkpoint["model"]
|
| 162 |
+
|
| 163 |
+
return model
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
if __name__ == "__main__":
|
| 167 |
+
print("Testing TransformerSentimentModel...")
|
| 168 |
+
|
| 169 |
+
model = TransformerSentimentModel(
|
| 170 |
+
model_name="bert-base-multilingual-cased",
|
| 171 |
+
num_labels=4,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
print(f"Total parameters: {model.get_num_parameters():,}")
|
| 175 |
+
print(f"Trainable parameters: {model.get_num_trainable_parameters():,}")
|
pipelines/__init__.py
ADDED
|
File without changes
|
pipelines/data_pipeline.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end data processing pipeline."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import logging
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import List
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 10 |
+
|
| 11 |
+
from src.utils.logger import setup_logger
|
| 12 |
+
|
| 13 |
+
logger = setup_logger("data_pipeline")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def process_audio(input_dir: str, output_dir: str, config: dict = None) -> List[str]:
|
| 17 |
+
"""Process audio files."""
|
| 18 |
+
from src.data_processing.audio_processor import AudioProcessor
|
| 19 |
+
|
| 20 |
+
logger.info(f"Processing audio from {input_dir}")
|
| 21 |
+
|
| 22 |
+
processor = AudioProcessor(
|
| 23 |
+
sample_rate=config.get("sample_rate", 16000),
|
| 24 |
+
n_fft=config.get("n_fft", 512),
|
| 25 |
+
hop_length=config.get("hop_length", 160),
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
results = processor.batch_process(input_dir, output_dir)
|
| 29 |
+
|
| 30 |
+
logger.info(f"Processed {len(results)} audio files")
|
| 31 |
+
return [r["output_path"] for r in results]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def process_text(input_path: str, output_path: str, config: dict = None) -> str:
|
| 35 |
+
"""Process text data."""
|
| 36 |
+
from src.data_processing.text_normalizer import MyanmarTextNormalizer
|
| 37 |
+
|
| 38 |
+
logger.info(f"Processing text from {input_path}")
|
| 39 |
+
|
| 40 |
+
normalizer = MyanmarTextNormalizer(
|
| 41 |
+
custom_rules_path=config.get("custom_rules") if config else None
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
import pandas as pd
|
| 45 |
+
|
| 46 |
+
if input_path.endswith(".csv"):
|
| 47 |
+
df = pd.read_csv(input_path)
|
| 48 |
+
texts = df["text"].tolist()
|
| 49 |
+
else:
|
| 50 |
+
raise ValueError(f"Unsupported input format: {input_path}")
|
| 51 |
+
|
| 52 |
+
normalized_texts = normalizer.normalize_corpus(texts)
|
| 53 |
+
df["text_normalized"] = normalized_texts
|
| 54 |
+
|
| 55 |
+
df.to_csv(output_path, index=False)
|
| 56 |
+
logger.info(f"Saved normalized text to {output_path}")
|
| 57 |
+
|
| 58 |
+
return output_path
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def augment_data(input_path: str, output_path: str, config: dict = None) -> str:
|
| 62 |
+
"""Augment training data."""
|
| 63 |
+
from src.augmentation.synonym_replacer import MyanmarSynonymReplacer
|
| 64 |
+
from src.augmentation.perturbator import TextPerturbator
|
| 65 |
+
|
| 66 |
+
logger.info(f"Augmenting data from {input_path}")
|
| 67 |
+
|
| 68 |
+
replacer = MyanmarSynonymReplacer()
|
| 69 |
+
perturbator = TextPerturbator()
|
| 70 |
+
|
| 71 |
+
import pandas as pd
|
| 72 |
+
import json
|
| 73 |
+
|
| 74 |
+
if input_path.endswith(".csv"):
|
| 75 |
+
df = pd.read_csv(input_path)
|
| 76 |
+
samples = df.to_dict("records")
|
| 77 |
+
elif input_path.endswith(".json"):
|
| 78 |
+
with open(input_path, "r") as f:
|
| 79 |
+
samples = json.load(f)
|
| 80 |
+
else:
|
| 81 |
+
raise ValueError(f"Unsupported format: {input_path}")
|
| 82 |
+
|
| 83 |
+
augmented = []
|
| 84 |
+
|
| 85 |
+
for sample in samples:
|
| 86 |
+
text = sample.get("text", "")
|
| 87 |
+
|
| 88 |
+
# Synonym replacement
|
| 89 |
+
aug_text, replacements = replacer.augment_text(
|
| 90 |
+
text,
|
| 91 |
+
replace_prob=config.get("synonym_prob", 0.3) if config else 0.3,
|
| 92 |
+
)
|
| 93 |
+
if replacements:
|
| 94 |
+
aug_sample = sample.copy()
|
| 95 |
+
aug_sample["text"] = aug_text
|
| 96 |
+
aug_sample["augmentation_type"] = "synonym"
|
| 97 |
+
aug_sample["replacements"] = replacements
|
| 98 |
+
augmented.append(aug_sample)
|
| 99 |
+
|
| 100 |
+
# Perturbation
|
| 101 |
+
aug_text, perturbations = perturbator.apply_random_perturbations(
|
| 102 |
+
text,
|
| 103 |
+
n_perturbations=config.get("n_perturbations", 2) if config else 2,
|
| 104 |
+
)
|
| 105 |
+
if perturbations:
|
| 106 |
+
aug_sample = sample.copy()
|
| 107 |
+
aug_sample["text"] = aug_text
|
| 108 |
+
aug_sample["augmentation_type"] = "perturbation"
|
| 109 |
+
aug_sample["perturbations"] = [p.value for p in perturbations]
|
| 110 |
+
augmented.append(aug_sample)
|
| 111 |
+
|
| 112 |
+
# Save augmented data
|
| 113 |
+
output_df = pd.DataFrame(augmented)
|
| 114 |
+
output_df.to_csv(output_path, index=False)
|
| 115 |
+
|
| 116 |
+
logger.info(f"Generated {len(augmented)} augmented samples")
|
| 117 |
+
return output_path
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def split_data(input_path: str, output_dir: str, config: dict = None) -> dict:
|
| 121 |
+
"""Split data into train/val/test."""
|
| 122 |
+
from sklearn.model_selection import train_test_split
|
| 123 |
+
|
| 124 |
+
logger.info(f"Splitting data from {input_path}")
|
| 125 |
+
|
| 126 |
+
import pandas as pd
|
| 127 |
+
|
| 128 |
+
df = pd.read_csv(input_path)
|
| 129 |
+
|
| 130 |
+
train_ratio = config.get("train_ratio", 0.8) if config else 0.8
|
| 131 |
+
val_ratio = config.get("val_ratio", 0.1) if config else 0.1
|
| 132 |
+
|
| 133 |
+
# First split: train vs rest
|
| 134 |
+
train_df, temp_df = train_test_split(
|
| 135 |
+
df, train_size=train_ratio, random_state=42
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
# Second split: val vs test
|
| 139 |
+
val_size = val_ratio / (1 - train_ratio)
|
| 140 |
+
val_df, test_df = train_test_split(
|
| 141 |
+
temp_df, train_size=val_size, random_state=42
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
# Save splits
|
| 145 |
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
| 146 |
+
|
| 147 |
+
train_df.to_csv(f"{output_dir}/train.csv", index=False)
|
| 148 |
+
val_df.to_csv(f"{output_dir}/val.csv", index=False)
|
| 149 |
+
test_df.to_csv(f"{output_dir}/test.csv", index=False)
|
| 150 |
+
|
| 151 |
+
logger.info(f"Saved: train={len(train_df)}, val={len(val_df)}, test={len(test_df)}")
|
| 152 |
+
|
| 153 |
+
return {
|
| 154 |
+
"train": f"{output_dir}/train.csv",
|
| 155 |
+
"val": f"{output_dir}/val.csv",
|
| 156 |
+
"test": f"{output_dir}/test.csv",
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def run_pipeline(input_path: str, output_dir: str, config: dict = None) -> dict:
|
| 161 |
+
"""Run the full data processing pipeline."""
|
| 162 |
+
logger.info("Starting data processing pipeline...")
|
| 163 |
+
|
| 164 |
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
| 165 |
+
|
| 166 |
+
# Step 1: Normalize text
|
| 167 |
+
normalized_path = f"{output_dir}/normalized.csv"
|
| 168 |
+
process_text(input_path, normalized_path, config)
|
| 169 |
+
|
| 170 |
+
# Step 2: Augment data
|
| 171 |
+
augmented_path = f"{output_dir}/augmented.csv"
|
| 172 |
+
augment_data(normalized_path, augmented_path, config)
|
| 173 |
+
|
| 174 |
+
# Step 3: Split data
|
| 175 |
+
splits = split_data(augmented_path, f"{output_dir}/splits", config)
|
| 176 |
+
|
| 177 |
+
logger.info("Pipeline complete!")
|
| 178 |
+
|
| 179 |
+
return {
|
| 180 |
+
"normalized": normalized_path,
|
| 181 |
+
"augmented": augmented_path,
|
| 182 |
+
"splits": splits,
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
if __name__ == "__main__":
|
| 187 |
+
parser = argparse.ArgumentParser(description="Data processing pipeline")
|
| 188 |
+
parser.add_argument("--input", type=str, required=True, help="Input data file")
|
| 189 |
+
parser.add_argument("--output", type=str, default="data/processed", help="Output directory")
|
| 190 |
+
|
| 191 |
+
args = parser.parse_args()
|
| 192 |
+
|
| 193 |
+
run_pipeline(args.input, args.output)
|
pipelines/deployment_pipeline.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model deployment pipeline for Myanmar Ghost."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import logging
|
| 5 |
+
import shutil
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 11 |
+
|
| 12 |
+
from src.utils.logger import setup_logger
|
| 13 |
+
|
| 14 |
+
logger = setup_logger("deployment_pipeline")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def export_model(model_path: str, output_dir: str, format: str = "pytorch") -> str:
|
| 18 |
+
"""Export model for deployment."""
|
| 19 |
+
logger.info(f"Exporting model from {model_path}")
|
| 20 |
+
|
| 21 |
+
import torch
|
| 22 |
+
|
| 23 |
+
# Load model
|
| 24 |
+
checkpoint = torch.load(model_path, map_location="cpu")
|
| 25 |
+
|
| 26 |
+
# Create output directory
|
| 27 |
+
export_path = Path(output_dir) / "exported_model"
|
| 28 |
+
export_path.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
if format == "pytorch":
|
| 31 |
+
# Save as PyTorch model
|
| 32 |
+
torch.save(checkpoint, export_path / "model.pt")
|
| 33 |
+
logger.info(f"Exported to {export_path}")
|
| 34 |
+
|
| 35 |
+
elif format == "onnx":
|
| 36 |
+
# Export to ONNX (requires model forward pass)
|
| 37 |
+
logger.warning("ONNX export not implemented yet")
|
| 38 |
+
|
| 39 |
+
elif format == "safetensors":
|
| 40 |
+
# Save as safetensors
|
| 41 |
+
try:
|
| 42 |
+
from safetensors.torch import save_file
|
| 43 |
+
if "model_state_dict" in checkpoint:
|
| 44 |
+
state_dict = checkpoint["model_state_dict"]
|
| 45 |
+
else:
|
| 46 |
+
state_dict = checkpoint
|
| 47 |
+
|
| 48 |
+
save_file(state_dict, export_path / "model.safetensors")
|
| 49 |
+
logger.info(f"Exported safetensors to {export_path}")
|
| 50 |
+
except ImportError:
|
| 51 |
+
logger.warning("safetensors not installed, using PyTorch format")
|
| 52 |
+
torch.save(checkpoint, export_path / "model.pt")
|
| 53 |
+
|
| 54 |
+
# Save metadata
|
| 55 |
+
metadata = {
|
| 56 |
+
"exported_at": datetime.now().isoformat(),
|
| 57 |
+
"format": format,
|
| 58 |
+
"original_path": model_path,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
import json
|
| 62 |
+
with open(export_path / "metadata.json", "w") as f:
|
| 63 |
+
json.dump(metadata, f, indent=2)
|
| 64 |
+
|
| 65 |
+
return str(export_path)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def create_docker_image(model_path: str, output_dir: str, tag: str = "myanmar-ghost") -> str:
|
| 69 |
+
"""Create Docker image for deployment."""
|
| 70 |
+
logger.info(f"Creating Docker image: {tag}")
|
| 71 |
+
|
| 72 |
+
import subprocess
|
| 73 |
+
|
| 74 |
+
# Create deployment directory
|
| 75 |
+
deploy_dir = Path(output_dir) / "docker"
|
| 76 |
+
deploy_dir.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
|
| 78 |
+
# Copy model
|
| 79 |
+
shutil.copytree(model_path, deploy_dir / "model", dirs_exist_ok=True)
|
| 80 |
+
|
| 81 |
+
# Create Dockerfile
|
| 82 |
+
dockerfile = f"""
|
| 83 |
+
FROM python:3.10-slim
|
| 84 |
+
|
| 85 |
+
WORKDIR /app
|
| 86 |
+
|
| 87 |
+
COPY model/ /app/model/
|
| 88 |
+
COPY deployment/api/ /app/api/
|
| 89 |
+
|
| 90 |
+
RUN pip install --no-cache-dir \\
|
| 91 |
+
torch \\
|
| 92 |
+
transformers \\
|
| 93 |
+
fastapi \\
|
| 94 |
+
uvicorn
|
| 95 |
+
|
| 96 |
+
EXPOSE 8000
|
| 97 |
+
|
| 98 |
+
CMD ["uvicorn", "api.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
with open(deploy_dir / "Dockerfile", "w") as f:
|
| 102 |
+
f.write(dockerfile)
|
| 103 |
+
|
| 104 |
+
# Build image
|
| 105 |
+
result = subprocess.run(
|
| 106 |
+
["docker", "build", "-t", tag, "."],
|
| 107 |
+
cwd=deploy_dir,
|
| 108 |
+
capture_output=True,
|
| 109 |
+
text=True,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if result.returncode != 0:
|
| 113 |
+
logger.error(f"Docker build failed: {result.stderr}")
|
| 114 |
+
raise RuntimeError("Docker build failed")
|
| 115 |
+
|
| 116 |
+
logger.info(f"Docker image created: {tag}")
|
| 117 |
+
return tag
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def push_to_huggingface(model_path: str, repo_id: str) -> str:
|
| 121 |
+
"""Push model to HuggingFace Hub."""
|
| 122 |
+
logger.info(f"Pushing model to HuggingFace: {repo_id}")
|
| 123 |
+
|
| 124 |
+
import subprocess
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
# Use huggingface_hub
|
| 128 |
+
from huggingface_hub import HfApi, create_repo
|
| 129 |
+
|
| 130 |
+
api = HfApi()
|
| 131 |
+
|
| 132 |
+
# Create repo if doesn't exist
|
| 133 |
+
try:
|
| 134 |
+
create_repo(repo_id, repo_type="model", exist_ok=True)
|
| 135 |
+
except Exception:
|
| 136 |
+
pass
|
| 137 |
+
|
| 138 |
+
# Upload folder
|
| 139 |
+
api.upload_folder(
|
| 140 |
+
folder_path=model_path,
|
| 141 |
+
repo_id=repo_id,
|
| 142 |
+
repo_type="model",
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
logger.info(f"Pushed to https://huggingface.co/{repo_id}")
|
| 146 |
+
return f"https://huggingface.co/{repo_id}"
|
| 147 |
+
|
| 148 |
+
except ImportError:
|
| 149 |
+
logger.warning("huggingface_hub not installed, using CLI")
|
| 150 |
+
|
| 151 |
+
result = subprocess.run([
|
| 152 |
+
"huggingface-cli", "upload",
|
| 153 |
+
repo_id,
|
| 154 |
+
model_path,
|
| 155 |
+
], capture_output=True, text=True)
|
| 156 |
+
|
| 157 |
+
if result.returncode != 0:
|
| 158 |
+
logger.error(f"HuggingFace upload failed: {result.stderr}")
|
| 159 |
+
raise RuntimeError("HuggingFace upload failed")
|
| 160 |
+
|
| 161 |
+
return f"https://huggingface.co/{repo_id}"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def deploy_to_render(api_token: str, service_name: str, model_path: str) -> str:
|
| 165 |
+
"""Deploy to Render using blueprint."""
|
| 166 |
+
logger.info(f"Deploying to Render: {service_name}")
|
| 167 |
+
|
| 168 |
+
import subprocess
|
| 169 |
+
|
| 170 |
+
# Create render.yaml
|
| 171 |
+
render_yaml = f"""
|
| 172 |
+
services:
|
| 173 |
+
- type: web
|
| 174 |
+
name: {service_name}
|
| 175 |
+
env: docker
|
| 176 |
+
repo: {model_path}
|
| 177 |
+
envVars:
|
| 178 |
+
- key: MODEL_PATH
|
| 179 |
+
value: /app/model
|
| 180 |
+
"""
|
| 181 |
+
|
| 182 |
+
with open("render.yaml", "w") as f:
|
| 183 |
+
f.write(render_yaml)
|
| 184 |
+
|
| 185 |
+
logger.info("Created render.yaml - deploy manually via Render dashboard")
|
| 186 |
+
|
| 187 |
+
return "render.yaml created"
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def run_deployment_pipeline(
|
| 191 |
+
model_path: str,
|
| 192 |
+
output_dir: str = "outputs/deployment",
|
| 193 |
+
format: str = "safetensors",
|
| 194 |
+
push_hub: bool = False,
|
| 195 |
+
repo_id: str = None,
|
| 196 |
+
) -> dict:
|
| 197 |
+
"""Run full deployment pipeline."""
|
| 198 |
+
logger.info("Starting deployment pipeline...")
|
| 199 |
+
|
| 200 |
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
| 201 |
+
|
| 202 |
+
# Export model
|
| 203 |
+
export_path = export_model(model_path, output_dir, format)
|
| 204 |
+
|
| 205 |
+
results = {
|
| 206 |
+
"export_path": export_path,
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
# Push to HuggingFace if requested
|
| 210 |
+
if push_hub and repo_id:
|
| 211 |
+
hub_url = push_to_huggingface(export_path, repo_id)
|
| 212 |
+
results["hub_url"] = hub_url
|
| 213 |
+
|
| 214 |
+
logger.info("Deployment pipeline complete!")
|
| 215 |
+
|
| 216 |
+
return results
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
if __name__ == "__main__":
|
| 220 |
+
parser = argparse.ArgumentParser(description="Model deployment pipeline")
|
| 221 |
+
parser.add_argument("--model_path", type=str, required=True)
|
| 222 |
+
parser.add_argument("--output_dir", type=str, default="outputs/deployment")
|
| 223 |
+
parser.add_argument("--format", type=str, default="safetensors")
|
| 224 |
+
parser.add_argument("--push_hub", action="store_true")
|
| 225 |
+
parser.add_argument("--repo_id", type=str, default=None)
|
| 226 |
+
|
| 227 |
+
args = parser.parse_args()
|
| 228 |
+
run_deployment_pipeline(
|
| 229 |
+
args.model_path,
|
| 230 |
+
args.output_dir,
|
| 231 |
+
args.format,
|
| 232 |
+
args.push_hub,
|
| 233 |
+
args.repo_id,
|
| 234 |
+
)
|
pipelines/evaluation_pipeline.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end model evaluation pipeline."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import sys
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
| 11 |
+
|
| 12 |
+
from src.utils.logger import setup_logger
|
| 13 |
+
from src.utils.visualization import (
|
| 14 |
+
plot_confusion_matrix,
|
| 15 |
+
plot_label_distribution,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
logger = setup_logger("evaluation_pipeline")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def evaluate_model(model_path: str, test_data: str, output_dir: str) -> dict:
|
| 22 |
+
"""Evaluate a trained model."""
|
| 23 |
+
logger.info(f"Evaluating model: {model_path}")
|
| 24 |
+
|
| 25 |
+
# Run evaluation
|
| 26 |
+
import subprocess
|
| 27 |
+
|
| 28 |
+
result = subprocess.run([
|
| 29 |
+
"python", "src/models/evaluate.py",
|
| 30 |
+
"--model_path", model_path,
|
| 31 |
+
"--data_path", test_data,
|
| 32 |
+
"--output_path", f"{output_dir}/results.json",
|
| 33 |
+
], capture_output=True, text=True)
|
| 34 |
+
|
| 35 |
+
logger.info(result.stdout)
|
| 36 |
+
if result.returncode != 0:
|
| 37 |
+
logger.error(result.stderr)
|
| 38 |
+
raise RuntimeError("Evaluation failed")
|
| 39 |
+
|
| 40 |
+
# Load results
|
| 41 |
+
with open(f"{output_dir}/results.json", "r") as f:
|
| 42 |
+
results = json.load(f)
|
| 43 |
+
|
| 44 |
+
return results
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def generate_report(results: dict, output_dir: str) -> str:
|
| 48 |
+
"""Generate evaluation report with visualizations."""
|
| 49 |
+
logger.info("Generating evaluation report...")
|
| 50 |
+
|
| 51 |
+
report_path = f"{output_dir}/report.html"
|
| 52 |
+
|
| 53 |
+
# Create visualizations
|
| 54 |
+
cm = results["confusion_matrix"]
|
| 55 |
+
class_names = results["class_names"]
|
| 56 |
+
|
| 57 |
+
plot_confusion_matrix(
|
| 58 |
+
cm, class_names,
|
| 59 |
+
title="Sentiment Classification Confusion Matrix",
|
| 60 |
+
output_path=f"{output_dir}/confusion_matrix.png",
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# Create HTML report
|
| 64 |
+
html = f"""
|
| 65 |
+
<!DOCTYPE html>
|
| 66 |
+
<html>
|
| 67 |
+
<head>
|
| 68 |
+
<title>Myanmar Ghost Evaluation Report</title>
|
| 69 |
+
<style>
|
| 70 |
+
body {{ font-family: Arial, sans-serif; margin: 40px; }}
|
| 71 |
+
.metric {{ display: inline-block; margin: 10px; padding: 20px;
|
| 72 |
+
background: #f0f0f0; border-radius: 8px; }}
|
| 73 |
+
.metric-value {{ font-size: 32px; font-weight: bold; color: #2196F3; }}
|
| 74 |
+
.metric-label {{ font-size: 14px; color: #666; }}
|
| 75 |
+
img {{ max-width: 600px; margin: 20px 0; }}
|
| 76 |
+
</style>
|
| 77 |
+
</head>
|
| 78 |
+
<body>
|
| 79 |
+
<h1>🇲🇲 Myanmar Ghost Evaluation Report</h1>
|
| 80 |
+
<p>Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
|
| 81 |
+
|
| 82 |
+
<h2>📊 Metrics</h2>
|
| 83 |
+
<div class="metrics">
|
| 84 |
+
<div class="metric">
|
| 85 |
+
<div class="metric-value">{results['metrics']['accuracy']:.2%}</div>
|
| 86 |
+
<div class="metric-label">Accuracy</div>
|
| 87 |
+
</div>
|
| 88 |
+
<div class="metric">
|
| 89 |
+
<div class="metric-value">{results['metrics']['f1_weighted']:.2%}</div>
|
| 90 |
+
<div class="metric-label">F1 (weighted)</div>
|
| 91 |
+
</div>
|
| 92 |
+
<div class="metric">
|
| 93 |
+
<div class="metric-value">{results['metrics']['precision_weighted']:.2%}</div>
|
| 94 |
+
<div class="metric-label">Precision</div>
|
| 95 |
+
</div>
|
| 96 |
+
<div class="metric">
|
| 97 |
+
<div class="metric-value">{results['metrics']['recall_weighted']:.2%}</div>
|
| 98 |
+
<div class="metric-label">Recall</div>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<h2>📈 Confusion Matrix</h2>
|
| 103 |
+
<img src="confusion_matrix.png" alt="Confusion Matrix">
|
| 104 |
+
|
| 105 |
+
<h2>📋 Per-Class Performance</h2>
|
| 106 |
+
<table border="1" cellpadding="10">
|
| 107 |
+
<tr>
|
| 108 |
+
<th>Class</th>
|
| 109 |
+
<th>F1 Score</th>
|
| 110 |
+
</tr>
|
| 111 |
+
"""
|
| 112 |
+
|
| 113 |
+
for name in class_names:
|
| 114 |
+
f1_key = f"f1_{name}"
|
| 115 |
+
if f1_key in results["metrics"]:
|
| 116 |
+
html += f"""
|
| 117 |
+
<tr>
|
| 118 |
+
<td>{name}</td>
|
| 119 |
+
<td>{results['metrics'][f1_key]:.4f}</td>
|
| 120 |
+
</tr>
|
| 121 |
+
"""
|
| 122 |
+
|
| 123 |
+
html += """
|
| 124 |
+
</table>
|
| 125 |
+
</body>
|
| 126 |
+
</html>
|
| 127 |
+
"""
|
| 128 |
+
|
| 129 |
+
with open(report_path, "w", encoding="utf-8") as f:
|
| 130 |
+
f.write(html)
|
| 131 |
+
|
| 132 |
+
logger.info(f"Report saved to {report_path}")
|
| 133 |
+
return report_path
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def compare_models(model_results: list, output_dir: str) -> dict:
|
| 137 |
+
"""Compare multiple model evaluations."""
|
| 138 |
+
logger.info(f"Comparing {len(model_results)} models...")
|
| 139 |
+
|
| 140 |
+
comparison = {
|
| 141 |
+
"models": [],
|
| 142 |
+
"best_model": None,
|
| 143 |
+
"best_f1": 0,
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
for result in model_results:
|
| 147 |
+
model_name = result.get("model_name", "unknown")
|
| 148 |
+
metrics = result.get("metrics", {})
|
| 149 |
+
|
| 150 |
+
comparison["models"].append({
|
| 151 |
+
"name": model_name,
|
| 152 |
+
"accuracy": metrics.get("accuracy", 0),
|
| 153 |
+
"f1_weighted": metrics.get("f1_weighted", 0),
|
| 154 |
+
"f1_macro": metrics.get("f1_macro", 0),
|
| 155 |
+
})
|
| 156 |
+
|
| 157 |
+
if metrics.get("f1_weighted", 0) > comparison["best_f1"]:
|
| 158 |
+
comparison["best_f1"] = metrics.get("f1_weighted", 0)
|
| 159 |
+
comparison["best_model"] = model_name
|
| 160 |
+
|
| 161 |
+
# Save comparison
|
| 162 |
+
with open(f"{output_dir}/model_comparison.json", "w") as f:
|
| 163 |
+
json.dump(comparison, f, indent=2)
|
| 164 |
+
|
| 165 |
+
logger.info(f"Best model: {comparison['best_model']} (F1: {comparison['best_f1']:.4f})")
|
| 166 |
+
|
| 167 |
+
return comparison
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def run_evaluation_pipeline(
|
| 171 |
+
model_path: str,
|
| 172 |
+
test_data: str,
|
| 173 |
+
output_dir: str = "outputs/evaluation",
|
| 174 |
+
) -> dict:
|
| 175 |
+
"""Run full evaluation pipeline."""
|
| 176 |
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
| 177 |
+
|
| 178 |
+
# Evaluate model
|
| 179 |
+
results = evaluate_model(model_path, test_data, output_dir)
|
| 180 |
+
|
| 181 |
+
# Generate report
|
| 182 |
+
report_path = generate_report(results, output_dir)
|
| 183 |
+
|
| 184 |
+
return {
|
| 185 |
+
"results": results,
|
| 186 |
+
"report": report_path,
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
if __name__ == "__main__":
|
| 191 |
+
parser = argparse.ArgumentParser()
|
| 192 |
+
parser.add_argument("--model_path", type=str, required=True)
|
| 193 |
+
parser.add_argument("--test_data", type=str, required=True)
|
| 194 |
+
parser.add_argument("--output_dir", type=str, default="outputs/evaluation")
|
| 195 |
+
|
| 196 |
+
args = parser.parse_args()
|
| 197 |
+
run_evaluation_pipeline(args.model_path, args.test_data, args.output_dir)
|
utils/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utility modules for Myanmar Ghost project."""
|
| 2 |
+
|
| 3 |
+
from .logger import setup_logger, get_logger
|
| 4 |
+
from .metrics import compute_metrics, MetricsTracker
|
| 5 |
+
from .file_utils import load_json, save_json, load_yaml, save_yaml
|
| 6 |
+
from .visualization import plot_training_curves, plot_confusion_matrix
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"setup_logger",
|
| 10 |
+
"get_logger",
|
| 11 |
+
"compute_metrics",
|
| 12 |
+
"MetricsTracker",
|
| 13 |
+
"load_json",
|
| 14 |
+
"save_json",
|
| 15 |
+
"load_yaml",
|
| 16 |
+
"save_yaml",
|
| 17 |
+
"plot_training_curves",
|
| 18 |
+
"plot_confusion_matrix",
|
| 19 |
+
]
|
utils/file_utils.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""File I/O utilities for Myanmar Ghost project."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Dict, List, Optional
|
| 7 |
+
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import yaml
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def load_json(path: str) -> Any:
|
| 13 |
+
"""Load JSON file."""
|
| 14 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 15 |
+
return json.load(f)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def save_json(data: Any, path: str, indent: int = 2) -> None:
|
| 19 |
+
"""Save data to JSON file."""
|
| 20 |
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
| 21 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 22 |
+
json.dump(data, f, indent=indent, ensure_ascii=False)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def load_yaml(path: str) -> Dict:
|
| 26 |
+
"""Load YAML file."""
|
| 27 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 28 |
+
return yaml.safe_load(f)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def save_yaml(data: Dict, path: str) -> None:
|
| 32 |
+
"""Save data to YAML file."""
|
| 33 |
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 35 |
+
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def load_jsonl(path: str) -> List[Dict]:
|
| 39 |
+
"""Load JSONL file (one JSON object per line)."""
|
| 40 |
+
data = []
|
| 41 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 42 |
+
for line in f:
|
| 43 |
+
if line.strip():
|
| 44 |
+
data.append(json.loads(line))
|
| 45 |
+
return data
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def save_jsonl(data: List[Dict], path: str) -> None:
|
| 49 |
+
"""Save data to JSONL file."""
|
| 50 |
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 52 |
+
for item in data:
|
| 53 |
+
f.write(json.dumps(item, ensure_ascii=False) + "\n")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def load_csv(path: str) -> pd.DataFrame:
|
| 57 |
+
"""Load CSV file as DataFrame."""
|
| 58 |
+
return pd.read_csv(path)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def save_csv(df: pd.DataFrame, path: str, index: bool = False) -> None:
|
| 62 |
+
"""Save DataFrame to CSV file."""
|
| 63 |
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
| 64 |
+
df.to_csv(path, index=index)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def ensure_dir(path: str) -> Path:
|
| 68 |
+
"""Ensure directory exists."""
|
| 69 |
+
p = Path(path)
|
| 70 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 71 |
+
return p
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def list_files(
|
| 75 |
+
directory: str,
|
| 76 |
+
pattern: str = "*",
|
| 77 |
+
recursive: bool = False,
|
| 78 |
+
) -> List[Path]:
|
| 79 |
+
"""List files in directory matching pattern."""
|
| 80 |
+
p = Path(directory)
|
| 81 |
+
if recursive:
|
| 82 |
+
return list(p.rglob(pattern))
|
| 83 |
+
return list(p.glob(pattern))
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def get_file_size(path: str) -> int:
|
| 87 |
+
"""Get file size in bytes."""
|
| 88 |
+
return os.path.getsize(path)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def copy_file(src: str, dst: str) -> None:
|
| 92 |
+
"""Copy file from src to dst."""
|
| 93 |
+
import shutil
|
| 94 |
+
Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
| 95 |
+
shutil.copy2(src, dst)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def move_file(src: str, dst: str) -> None:
|
| 99 |
+
"""Move file from src to dst."""
|
| 100 |
+
import shutil
|
| 101 |
+
Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
| 102 |
+
shutil.move(src, dst)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def delete_file(path: str) -> None:
|
| 106 |
+
"""Delete file."""
|
| 107 |
+
Path(path).unlink(missing_ok=True)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class ConfigManager:
|
| 111 |
+
"""Manage configuration files."""
|
| 112 |
+
|
| 113 |
+
def __init__(self, config_dir: str = "configs"):
|
| 114 |
+
self.config_dir = Path(config_dir)
|
| 115 |
+
|
| 116 |
+
def load(self, name: str, config_type: str = "yaml") -> Dict:
|
| 117 |
+
"""Load configuration by name."""
|
| 118 |
+
path = self.config_dir / f"{name}.{config_type}"
|
| 119 |
+
|
| 120 |
+
if config_type == "yaml":
|
| 121 |
+
return load_yaml(str(path))
|
| 122 |
+
elif config_type == "json":
|
| 123 |
+
return load_json(str(path))
|
| 124 |
+
else:
|
| 125 |
+
raise ValueError(f"Unsupported config type: {config_type}")
|
| 126 |
+
|
| 127 |
+
def save(self, name: str, config: Dict, config_type: str = "yaml") -> None:
|
| 128 |
+
"""Save configuration by name."""
|
| 129 |
+
path = self.config_dir / f"{name}.{config_type}"
|
| 130 |
+
|
| 131 |
+
if config_type == "yaml":
|
| 132 |
+
save_yaml(config, str(path))
|
| 133 |
+
elif config_type == "json":
|
| 134 |
+
save_json(config, str(path))
|
| 135 |
+
else:
|
| 136 |
+
raise ValueError(f"Unsupported config type: {config_type}")
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
if __name__ == "__main__":
|
| 140 |
+
# Test file utilities
|
| 141 |
+
print("File utilities loaded")
|
| 142 |
+
print(f"Current directory: {Path.cwd()}")
|
utils/logger.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Logging utilities for Myanmar Ghost project."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import sys
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from loguru import logger as _logger
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def setup_logger(
|
| 13 |
+
name: str = "myanmar_ghost",
|
| 14 |
+
log_dir: Optional[str] = None,
|
| 15 |
+
level: str = "INFO",
|
| 16 |
+
format: str = None,
|
| 17 |
+
) -> logging.Logger:
|
| 18 |
+
"""Set up logger with file and console output.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
name: Logger name
|
| 22 |
+
log_dir: Directory for log files
|
| 23 |
+
level: Logging level
|
| 24 |
+
format: Custom log format
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
Configured logger
|
| 28 |
+
"""
|
| 29 |
+
if format is None:
|
| 30 |
+
format = (
|
| 31 |
+
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
|
| 32 |
+
"<level>{level: <8}</level> | "
|
| 33 |
+
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | "
|
| 34 |
+
"<level>{message}</level>"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Remove default handler
|
| 38 |
+
_logger.remove()
|
| 39 |
+
|
| 40 |
+
# Console output
|
| 41 |
+
_logger.add(
|
| 42 |
+
sys.stdout,
|
| 43 |
+
format=format,
|
| 44 |
+
level=level,
|
| 45 |
+
colorize=True,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# File output
|
| 49 |
+
if log_dir:
|
| 50 |
+
log_path = Path(log_dir)
|
| 51 |
+
log_path.mkdir(parents=True, exist_ok=True)
|
| 52 |
+
|
| 53 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 54 |
+
log_file = log_path / f"{name}_{timestamp}.log"
|
| 55 |
+
|
| 56 |
+
_logger.add(
|
| 57 |
+
log_file,
|
| 58 |
+
format=format,
|
| 59 |
+
level=level,
|
| 60 |
+
rotation="100 MB",
|
| 61 |
+
retention="30 days",
|
| 62 |
+
compression="zip",
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
return _logger
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def get_logger(name: str = None) -> logging.Logger:
|
| 69 |
+
"""Get logger instance.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
name: Logger name (optional)
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
Logger instance
|
| 76 |
+
"""
|
| 77 |
+
return _logger
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class TrainingLogger:
|
| 81 |
+
"""Logger for training metrics and progress."""
|
| 82 |
+
|
| 83 |
+
def __init__(
|
| 84 |
+
self,
|
| 85 |
+
log_dir: str = "outputs/logs",
|
| 86 |
+
experiment_name: str = "experiment",
|
| 87 |
+
):
|
| 88 |
+
self.log_dir = Path(log_dir)
|
| 89 |
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
| 90 |
+
|
| 91 |
+
self.experiment_name = experiment_name
|
| 92 |
+
self.metrics_history = []
|
| 93 |
+
|
| 94 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 95 |
+
self.log_file = self.log_dir / f"{experiment_name}_{timestamp}.log"
|
| 96 |
+
|
| 97 |
+
def log_metrics(self, metrics: dict, step: int) -> None:
|
| 98 |
+
"""Log metrics at a specific step."""
|
| 99 |
+
entry = {
|
| 100 |
+
"step": step,
|
| 101 |
+
"timestamp": datetime.now().isoformat(),
|
| 102 |
+
**metrics,
|
| 103 |
+
}
|
| 104 |
+
self.metrics_history.append(entry)
|
| 105 |
+
|
| 106 |
+
log_line = f"Step {step}: " + ", ".join(
|
| 107 |
+
f"{k}={v:.4f}" if isinstance(v, float) else f"{k}={v}"
|
| 108 |
+
for k, v in metrics.items()
|
| 109 |
+
)
|
| 110 |
+
_logger.info(log_line)
|
| 111 |
+
|
| 112 |
+
def log_epoch(self, epoch: int, metrics: dict) -> None:
|
| 113 |
+
"""Log metrics at epoch end."""
|
| 114 |
+
entry = {
|
| 115 |
+
"epoch": epoch,
|
| 116 |
+
"timestamp": datetime.now().isoformat(),
|
| 117 |
+
**metrics,
|
| 118 |
+
}
|
| 119 |
+
self.metrics_history.append(entry)
|
| 120 |
+
|
| 121 |
+
log_line = f"Epoch {epoch}: " + ", ".join(
|
| 122 |
+
f"{k}={v:.4f}" if isinstance(v, float) else f"{k}={v}"
|
| 123 |
+
for k, v in metrics.items()
|
| 124 |
+
)
|
| 125 |
+
_logger.info(log_line)
|
| 126 |
+
|
| 127 |
+
def save_history(self) -> str:
|
| 128 |
+
"""Save metrics history to file."""
|
| 129 |
+
import json
|
| 130 |
+
|
| 131 |
+
with open(self.log_file, "w", encoding="utf-8") as f:
|
| 132 |
+
json.dump(self.metrics_history, f, indent=2)
|
| 133 |
+
|
| 134 |
+
return str(self.log_file)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
if __name__ == "__main__":
|
| 138 |
+
logger = setup_logger("test_logger", "outputs/logs")
|
| 139 |
+
logger.info("Logger initialized successfully")
|
utils/metrics.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Metrics computation utilities for Myanmar Ghost project."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Any, Dict, List, Optional
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch
|
| 8 |
+
from sklearn.metrics import (
|
| 9 |
+
accuracy_score,
|
| 10 |
+
f1_score,
|
| 11 |
+
precision_score,
|
| 12 |
+
recall_score,
|
| 13 |
+
confusion_matrix,
|
| 14 |
+
classification_report,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class MetricResult:
|
| 20 |
+
"""Result of metric computation."""
|
| 21 |
+
name: str
|
| 22 |
+
value: float
|
| 23 |
+
std: Optional[float] = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def compute_accuracy(predictions: List[int], targets: List[int]) -> float:
|
| 27 |
+
"""Compute accuracy."""
|
| 28 |
+
return accuracy_score(targets, predictions)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def compute_f1(
|
| 32 |
+
predictions: List[int],
|
| 33 |
+
targets: List[int],
|
| 34 |
+
average: str = "weighted",
|
| 35 |
+
) -> float:
|
| 36 |
+
"""Compute F1 score."""
|
| 37 |
+
return f1_score(targets, predictions, average=average, zero_division=0)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def compute_precision(
|
| 41 |
+
predictions: List[int],
|
| 42 |
+
targets: List[int],
|
| 43 |
+
average: str = "weighted",
|
| 44 |
+
) -> float:
|
| 45 |
+
"""Compute precision score."""
|
| 46 |
+
return precision_score(targets, predictions, average=average, zero_division=0)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def compute_recall(
|
| 50 |
+
predictions: List[int],
|
| 51 |
+
targets: List[int],
|
| 52 |
+
average: str = "weighted",
|
| 53 |
+
) -> float:
|
| 54 |
+
"""Compute recall score."""
|
| 55 |
+
return recall_score(targets, predictions, average=average, zero_division=0)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def compute_confusion_matrix(
|
| 59 |
+
predictions: List[int],
|
| 60 |
+
targets: List[int],
|
| 61 |
+
) -> np.ndarray:
|
| 62 |
+
"""Compute confusion matrix."""
|
| 63 |
+
return confusion_matrix(targets, predictions)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def compute_metrics(
|
| 67 |
+
predictions: List[int],
|
| 68 |
+
targets: List[int],
|
| 69 |
+
class_names: Optional[List[str]] = None,
|
| 70 |
+
) -> Dict[str, Any]:
|
| 71 |
+
"""Compute all metrics.
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
predictions: List of predicted labels
|
| 75 |
+
targets: List of ground truth labels
|
| 76 |
+
class_names: Optional list of class names
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
Dictionary of metrics
|
| 80 |
+
"""
|
| 81 |
+
metrics = {
|
| 82 |
+
"accuracy": compute_accuracy(predictions, targets),
|
| 83 |
+
"f1_weighted": compute_f1(predictions, targets, "weighted"),
|
| 84 |
+
"f1_macro": compute_f1(predictions, targets, "macro"),
|
| 85 |
+
"f1_micro": compute_f1(predictions, targets, "micro"),
|
| 86 |
+
"precision_weighted": compute_precision(predictions, targets, "weighted"),
|
| 87 |
+
"precision_macro": compute_precision(predictions, targets, "macro"),
|
| 88 |
+
"recall_weighted": compute_recall(predictions, targets, "weighted"),
|
| 89 |
+
"recall_macro": compute_recall(predictions, targets, "macro"),
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
# Per-class metrics
|
| 93 |
+
labels = list(range(len(class_names))) if class_names else None
|
| 94 |
+
per_class_f1 = f1_score(targets, predictions, labels=labels, average=None, zero_division=0)
|
| 95 |
+
|
| 96 |
+
if class_names:
|
| 97 |
+
for i, name in enumerate(class_names):
|
| 98 |
+
metrics[f"f1_{name}"] = per_class_f1[i]
|
| 99 |
+
|
| 100 |
+
return metrics
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class MetricsTracker:
|
| 104 |
+
"""Track metrics during training."""
|
| 105 |
+
|
| 106 |
+
def __init__(
|
| 107 |
+
self,
|
| 108 |
+
metrics: List[str] = None,
|
| 109 |
+
class_names: Optional[List[str]] = None,
|
| 110 |
+
):
|
| 111 |
+
self.metrics = metrics or ["loss", "accuracy", "f1"]
|
| 112 |
+
self.class_names = class_names or ["negative", "neutral", "positive", "sarcastic"]
|
| 113 |
+
|
| 114 |
+
self.history = {m: [] for m in self.metrics}
|
| 115 |
+
self.best_values = {m: float("-inf") for m in self.metrics}
|
| 116 |
+
self.best_epochs = {m: 0 for m in self.metrics}
|
| 117 |
+
|
| 118 |
+
def update(self, metrics: Dict[str, float], step: int) -> None:
|
| 119 |
+
"""Update metrics at current step."""
|
| 120 |
+
for name, value in metrics.items():
|
| 121 |
+
if name in self.metrics:
|
| 122 |
+
self.history[name].append((step, value))
|
| 123 |
+
|
| 124 |
+
# Track best
|
| 125 |
+
if value > self.best_values[name]:
|
| 126 |
+
self.best_values[name] = value
|
| 127 |
+
self.best_epochs[name] = step
|
| 128 |
+
|
| 129 |
+
def get_current(self, metric_name: str) -> float:
|
| 130 |
+
"""Get current value of a metric."""
|
| 131 |
+
if metric_name in self.history and self.history[metric_name]:
|
| 132 |
+
return self.history[metric_name][-1][1]
|
| 133 |
+
return 0.0
|
| 134 |
+
|
| 135 |
+
def get_best(self, metric_name: str) -> tuple:
|
| 136 |
+
"""Get best value and epoch of a metric."""
|
| 137 |
+
return self.best_values.get(metric_name, 0), self.best_epochs.get(metric_name, 0)
|
| 138 |
+
|
| 139 |
+
def get_summary(self) -> Dict[str, Any]:
|
| 140 |
+
"""Get summary of all metrics."""
|
| 141 |
+
return {
|
| 142 |
+
"best": self.best_values,
|
| 143 |
+
"best_epochs": self.best_epochs,
|
| 144 |
+
"current": {m: self.get_current(m) for m in self.metrics},
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def compute_bleu(
|
| 149 |
+
predictions: List[str],
|
| 150 |
+
references: List[str],
|
| 151 |
+
) -> float:
|
| 152 |
+
"""Compute BLEU score for text generation."""
|
| 153 |
+
from sacrebleu import sentence_bleu
|
| 154 |
+
|
| 155 |
+
scores = []
|
| 156 |
+
for pred, ref in zip(predictions, references):
|
| 157 |
+
score = sentence_bleu(pred, [ref])
|
| 158 |
+
scores.append(score.score)
|
| 159 |
+
|
| 160 |
+
return np.mean(scores)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def compute_perplexity(
|
| 164 |
+
loss: float,
|
| 165 |
+
) -> float:
|
| 166 |
+
"""Compute perplexity from cross-entropy loss."""
|
| 167 |
+
return np.exp(loss)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
if __name__ == "__main__":
|
| 171 |
+
# Test metrics computation
|
| 172 |
+
predictions = [0, 1, 2, 0, 1, 2, 0, 1, 2]
|
| 173 |
+
targets = [0, 1, 2, 0, 1, 1, 0, 0, 2]
|
| 174 |
+
|
| 175 |
+
metrics = compute_metrics(predictions, targets)
|
| 176 |
+
print("Metrics:", metrics)
|
utils/visualization.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Visualization utilities for Myanmar Ghost project."""
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Any, Dict, List, Optional
|
| 5 |
+
|
| 6 |
+
import matplotlib.pyplot as plt
|
| 7 |
+
import numpy as np
|
| 8 |
+
import seaborn as sns
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def plot_training_curves(
|
| 12 |
+
history: Dict[str, List[float]],
|
| 13 |
+
metrics: List[str] = None,
|
| 14 |
+
title: str = "Training Curves",
|
| 15 |
+
output_path: Optional[str] = None,
|
| 16 |
+
figsize: tuple = (12, 8),
|
| 17 |
+
) -> plt.Figure:
|
| 18 |
+
"""Plot training curves for multiple metrics.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
history: Dictionary mapping metric names to lists of values
|
| 22 |
+
metrics: List of metrics to plot (default: all)
|
| 23 |
+
title: Plot title
|
| 24 |
+
output_path: Path to save figure
|
| 25 |
+
figsize: Figure size
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
Matplotlib figure
|
| 29 |
+
"""
|
| 30 |
+
if metrics is None:
|
| 31 |
+
metrics = list(history.keys())
|
| 32 |
+
|
| 33 |
+
n_metrics = len(metrics)
|
| 34 |
+
n_cols = min(2, n_metrics)
|
| 35 |
+
n_rows = (n_metrics + n_cols - 1) // n_cols
|
| 36 |
+
|
| 37 |
+
fig, axes = plt.subplots(n_rows, n_cols, figsize=figsize)
|
| 38 |
+
fig.suptitle(title, fontsize=16)
|
| 39 |
+
|
| 40 |
+
if n_metrics == 1:
|
| 41 |
+
axes = [axes]
|
| 42 |
+
else:
|
| 43 |
+
axes = axes.flatten() if hasattr(axes, 'flatten') else axes
|
| 44 |
+
|
| 45 |
+
for i, metric in enumerate(metrics):
|
| 46 |
+
ax = axes[i] if i < len(axes) else axes[0]
|
| 47 |
+
|
| 48 |
+
if metric in history:
|
| 49 |
+
values = history[metric]
|
| 50 |
+
steps = list(range(len(values)))
|
| 51 |
+
|
| 52 |
+
ax.plot(steps, values, marker='o', markersize=3)
|
| 53 |
+
ax.set_xlabel('Step/Epoch')
|
| 54 |
+
ax.set_ylabel(metric.capitalize())
|
| 55 |
+
ax.set_title(metric.capitalize())
|
| 56 |
+
ax.grid(True, alpha=0.3)
|
| 57 |
+
|
| 58 |
+
# Hide unused subplots
|
| 59 |
+
for i in range(n_metrics, len(axes)):
|
| 60 |
+
axes[i].set_visible(False)
|
| 61 |
+
|
| 62 |
+
plt.tight_layout()
|
| 63 |
+
|
| 64 |
+
if output_path:
|
| 65 |
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
| 66 |
+
plt.savefig(output_path, dpi=150, bbox_inches='tight')
|
| 67 |
+
|
| 68 |
+
return fig
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def plot_confusion_matrix(
|
| 72 |
+
cm: np.ndarray,
|
| 73 |
+
class_names: List[str],
|
| 74 |
+
title: str = "Confusion Matrix",
|
| 75 |
+
output_path: Optional[str] = None,
|
| 76 |
+
figsize: tuple = (10, 8),
|
| 77 |
+
normalize: bool = False,
|
| 78 |
+
) -> plt.Figure:
|
| 79 |
+
"""Plot confusion matrix.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
cm: Confusion matrix
|
| 83 |
+
class_names: Names of classes
|
| 84 |
+
title: Plot title
|
| 85 |
+
output_path: Path to save figure
|
| 86 |
+
figsize: Figure size
|
| 87 |
+
normalize: Whether to normalize
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
Matplotlib figure
|
| 91 |
+
"""
|
| 92 |
+
if normalize:
|
| 93 |
+
cm = cm.astype('float') / cm.sum(axis=1)[:, np.newaxis]
|
| 94 |
+
|
| 95 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 96 |
+
|
| 97 |
+
sns.heatmap(
|
| 98 |
+
cm,
|
| 99 |
+
annot=True,
|
| 100 |
+
fmt='.2f' if normalize else 'd',
|
| 101 |
+
cmap='Blues',
|
| 102 |
+
xticklabels=class_names,
|
| 103 |
+
yticklabels=class_names,
|
| 104 |
+
ax=ax,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
ax.set_xlabel('Predicted')
|
| 108 |
+
ax.set_ylabel('True')
|
| 109 |
+
ax.set_title(title)
|
| 110 |
+
|
| 111 |
+
plt.tight_layout()
|
| 112 |
+
|
| 113 |
+
if output_path:
|
| 114 |
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
| 115 |
+
plt.savefig(output_path, dpi=150, bbox_inches='tight')
|
| 116 |
+
|
| 117 |
+
return fig
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def plot_label_distribution(
|
| 121 |
+
labels: List[Any],
|
| 122 |
+
class_names: Optional[List[str]] = None,
|
| 123 |
+
title: str = "Label Distribution",
|
| 124 |
+
output_path: Optional[str] = None,
|
| 125 |
+
figsize: tuple = (10, 6),
|
| 126 |
+
) -> plt.Figure:
|
| 127 |
+
"""Plot distribution of labels.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
labels: List of labels
|
| 131 |
+
class_names: Names of classes
|
| 132 |
+
title: Plot title
|
| 133 |
+
output_path: Path to save figure
|
| 134 |
+
figsize: Figure size
|
| 135 |
+
|
| 136 |
+
Returns:
|
| 137 |
+
Matplotlib figure
|
| 138 |
+
"""
|
| 139 |
+
from collections import Counter
|
| 140 |
+
|
| 141 |
+
counts = Counter(labels)
|
| 142 |
+
|
| 143 |
+
if class_names:
|
| 144 |
+
labels_order = class_names
|
| 145 |
+
values = [counts.get(l, 0) for l in labels_order]
|
| 146 |
+
else:
|
| 147 |
+
labels_order = list(counts.keys())
|
| 148 |
+
values = list(counts.values())
|
| 149 |
+
|
| 150 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 151 |
+
|
| 152 |
+
bars = ax.bar(labels_order, values, color='steelblue', alpha=0.7)
|
| 153 |
+
|
| 154 |
+
# Add count labels on bars
|
| 155 |
+
for bar, count in zip(bars, values):
|
| 156 |
+
height = bar.get_height()
|
| 157 |
+
ax.text(
|
| 158 |
+
bar.get_x() + bar.get_width() / 2.,
|
| 159 |
+
height,
|
| 160 |
+
f'{int(count)}',
|
| 161 |
+
ha='center',
|
| 162 |
+
va='bottom',
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
ax.set_xlabel('Class')
|
| 166 |
+
ax.set_ylabel('Count')
|
| 167 |
+
ax.set_title(title)
|
| 168 |
+
ax.grid(True, alpha=0.3, axis='y')
|
| 169 |
+
|
| 170 |
+
plt.tight_layout()
|
| 171 |
+
|
| 172 |
+
if output_path:
|
| 173 |
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
| 174 |
+
plt.savefig(output_path, dpi=150, bbox_inches='tight')
|
| 175 |
+
|
| 176 |
+
return fig
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def plot_attention_weights(
|
| 180 |
+
attention_weights: np.ndarray,
|
| 181 |
+
tokens: List[str],
|
| 182 |
+
title: str = "Attention Weights",
|
| 183 |
+
output_path: Optional[str] = None,
|
| 184 |
+
figsize: tuple = (12, 10),
|
| 185 |
+
) -> plt.Figure:
|
| 186 |
+
"""Plot attention weights heatmap.
|
| 187 |
+
|
| 188 |
+
Args:
|
| 189 |
+
attention_weights: Attention weight matrix
|
| 190 |
+
tokens: List of tokens
|
| 191 |
+
title: Plot title
|
| 192 |
+
output_path: Path to save figure
|
| 193 |
+
figsize: Figure size
|
| 194 |
+
|
| 195 |
+
Returns:
|
| 196 |
+
Matplotlib figure
|
| 197 |
+
"""
|
| 198 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 199 |
+
|
| 200 |
+
sns.heatmap(
|
| 201 |
+
attention_weights,
|
| 202 |
+
xticklabels=tokens,
|
| 203 |
+
yticklabels=tokens,
|
| 204 |
+
cmap='viridis',
|
| 205 |
+
ax=ax,
|
| 206 |
+
cbar_kw={'label': 'Attention Weight'},
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
ax.set_xlabel('Key Tokens')
|
| 210 |
+
ax.set_ylabel('Query Tokens')
|
| 211 |
+
ax.set_title(title)
|
| 212 |
+
|
| 213 |
+
plt.tight_layout()
|
| 214 |
+
|
| 215 |
+
if output_path:
|
| 216 |
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
| 217 |
+
plt.savefig(output_path, dpi=150, bbox_inches='tight')
|
| 218 |
+
|
| 219 |
+
return fig
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def plot_loss_landscape(
|
| 223 |
+
losses: np.ndarray,
|
| 224 |
+
xlabel: str = "x",
|
| 225 |
+
ylabel: str = "y",
|
| 226 |
+
title: str = "Loss Landscape",
|
| 227 |
+
output_path: Optional[str] = None,
|
| 228 |
+
figsize: tuple = (10, 6),
|
| 229 |
+
) -> plt.Figure:
|
| 230 |
+
"""Plot loss landscape.
|
| 231 |
+
|
| 232 |
+
Args:
|
| 233 |
+
losses: 2D array of loss values
|
| 234 |
+
xlabel: Label for x-axis
|
| 235 |
+
ylabel: Label for y-axis
|
| 236 |
+
title: Plot title
|
| 237 |
+
output_path: Path to save figure
|
| 238 |
+
figsize: Figure size
|
| 239 |
+
|
| 240 |
+
Returns:
|
| 241 |
+
Matplotlib figure
|
| 242 |
+
"""
|
| 243 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 244 |
+
|
| 245 |
+
if losses.ndim == 1:
|
| 246 |
+
ax.plot(losses)
|
| 247 |
+
else:
|
| 248 |
+
sns.heatmap(losses, ax=ax, cmap='viridis')
|
| 249 |
+
|
| 250 |
+
ax.set_xlabel(xlabel)
|
| 251 |
+
ax.set_ylabel(ylabel)
|
| 252 |
+
ax.set_title(title)
|
| 253 |
+
|
| 254 |
+
plt.tight_layout()
|
| 255 |
+
|
| 256 |
+
if output_path:
|
| 257 |
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
| 258 |
+
plt.savefig(output_path, dpi=150, bbox_inches='tight')
|
| 259 |
+
|
| 260 |
+
return fig
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
if __name__ == "__main__":
|
| 264 |
+
print("Visualization utilities loaded")
|
| 265 |
+
print("Available functions:")
|
| 266 |
+
print(" - plot_training_curves")
|
| 267 |
+
print(" - plot_confusion_matrix")
|
| 268 |
+
print(" - plot_label_distribution")
|
| 269 |
+
print(" - plot_attention_weights")
|
| 270 |
+
print(" - plot_loss_landscape")
|
xai/__init__.py
ADDED
|
File without changes
|
xai/lime_explainer.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LIME explainer for Myanmar Ghost model.
|
| 2 |
+
|
| 3 |
+
Uses LIME (Local Interpretable Model-agnostic Explanations)
|
| 4 |
+
to explain individual predictions.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
from lime.lime_text import LimeTextExplainer
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ThankingLIMEExplainer:
|
| 18 |
+
"""LIME-based explainer for Myanmar text classification."""
|
| 19 |
+
|
| 20 |
+
def __init__(
|
| 21 |
+
self,
|
| 22 |
+
model,
|
| 23 |
+
tokenizer,
|
| 24 |
+
class_names: Optional[List[str]] = None,
|
| 25 |
+
kernel_width: float = 25.0,
|
| 26 |
+
):
|
| 27 |
+
"""
|
| 28 |
+
Args:
|
| 29 |
+
model: PyTorch model
|
| 30 |
+
tokenizer: Tokenizer
|
| 31 |
+
class_names: Names for output classes
|
| 32 |
+
kernel_width: Kernel width for LIME
|
| 33 |
+
"""
|
| 34 |
+
self.model = model
|
| 35 |
+
self.tokenizer = tokenizer
|
| 36 |
+
self.class_names = class_names or [
|
| 37 |
+
"negative", "neutral", "positive", "sarcastic"
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
self.model.eval()
|
| 41 |
+
|
| 42 |
+
# Create LIME explainer
|
| 43 |
+
self.explainer = LimeTextExplainer(
|
| 44 |
+
class_names=self.class_names,
|
| 45 |
+
kernel_width=kernel_width,
|
| 46 |
+
verbose=False,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
def _predict_proba(self, texts: List[str]) -> np.ndarray:
|
| 50 |
+
"""Prediction function for LIME.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
texts: List of text strings
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
Probability array (n_samples, n_classes)
|
| 57 |
+
"""
|
| 58 |
+
# Tokenize
|
| 59 |
+
encoding = self.tokenizer(
|
| 60 |
+
texts,
|
| 61 |
+
padding=True,
|
| 62 |
+
truncation=True,
|
| 63 |
+
max_length=128,
|
| 64 |
+
return_tensors="pt",
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
input_ids = encoding["input_ids"]
|
| 68 |
+
attention_mask = encoding["attention_mask"]
|
| 69 |
+
|
| 70 |
+
with torch.no_grad():
|
| 71 |
+
outputs = self.model(input_ids, attention_mask)
|
| 72 |
+
|
| 73 |
+
if hasattr(outputs, "logits"):
|
| 74 |
+
logits = outputs.logits
|
| 75 |
+
else:
|
| 76 |
+
logits = outputs
|
| 77 |
+
|
| 78 |
+
probs = torch.softmax(logits, dim=-1)
|
| 79 |
+
|
| 80 |
+
return probs.cpu().numpy()
|
| 81 |
+
|
| 82 |
+
def explain(
|
| 83 |
+
self,
|
| 84 |
+
text: str,
|
| 85 |
+
num_features: int = 10,
|
| 86 |
+
num_samples: int = 5000,
|
| 87 |
+
top_labels: int = 4,
|
| 88 |
+
) -> Any:
|
| 89 |
+
"""Explain a single text prediction.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
text: Myanmar text to explain
|
| 93 |
+
num_features: Number of features to show
|
| 94 |
+
num_samples: Number of samples for LIME
|
| 95 |
+
top_labels: Number of top labels to explain
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
LIME Explanation object
|
| 99 |
+
"""
|
| 100 |
+
logger.info(f"Explaining with LIME: {text[:50]}...")
|
| 101 |
+
|
| 102 |
+
explanation = self.explainer.explain_instance(
|
| 103 |
+
text,
|
| 104 |
+
self._predict_proba,
|
| 105 |
+
num_features=num_features,
|
| 106 |
+
num_samples=num_samples,
|
| 107 |
+
top_labels=top_labels,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
return explanation
|
| 111 |
+
|
| 112 |
+
def get_word_importance(
|
| 113 |
+
self,
|
| 114 |
+
text: str,
|
| 115 |
+
class_index: Optional[int] = None,
|
| 116 |
+
num_features: int = 10,
|
| 117 |
+
) -> List[Tuple[str, float]]:
|
| 118 |
+
"""Get word importance for a specific class.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
text: Myanmar text
|
| 122 |
+
class_index: Class index (None = predicted class)
|
| 123 |
+
num_features: Number of top features
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
List of (word, importance) tuples
|
| 127 |
+
"""
|
| 128 |
+
explanation = self.explain(text, num_features=num_features)
|
| 129 |
+
|
| 130 |
+
if class_index is None:
|
| 131 |
+
# Use predicted class
|
| 132 |
+
class_index = explanation.available_labels()[0]
|
| 133 |
+
|
| 134 |
+
exp_list = explanation.as_list(label=class_index)
|
| 135 |
+
|
| 136 |
+
return exp_list
|
| 137 |
+
|
| 138 |
+
def visualize(
|
| 139 |
+
self,
|
| 140 |
+
explanation: Any,
|
| 141 |
+
output_path: Optional[str] = None,
|
| 142 |
+
) -> str:
|
| 143 |
+
"""Generate text visualization of explanation.
|
| 144 |
+
|
| 145 |
+
Args:
|
| 146 |
+
explanation: LIME Explanation object
|
| 147 |
+
output_path: Optional path to save
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
Visualization text
|
| 151 |
+
"""
|
| 152 |
+
output = "\n" + "=" * 60 + "\n"
|
| 153 |
+
output += "LIME EXPLANATION\n"
|
| 154 |
+
output += "=" * 60 + "\n"
|
| 155 |
+
|
| 156 |
+
for label in explanation.available_labels()[:3]:
|
| 157 |
+
label_name = self.class_names[label]
|
| 158 |
+
output += f"\n{label_name.upper()}:\n"
|
| 159 |
+
output += "-" * 40 + "\n"
|
| 160 |
+
|
| 161 |
+
for word, weight in explanation.as_list(label=label):
|
| 162 |
+
sign = "+" if weight > 0 else ""
|
| 163 |
+
output += f" {word}: {sign}{weight:.4f}\n"
|
| 164 |
+
|
| 165 |
+
print(output)
|
| 166 |
+
|
| 167 |
+
if output_path:
|
| 168 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 169 |
+
f.write(output)
|
| 170 |
+
logger.info(f"Visualization saved to {output_path}")
|
| 171 |
+
|
| 172 |
+
return output
|
| 173 |
+
|
| 174 |
+
def batch_explain(
|
| 175 |
+
self,
|
| 176 |
+
texts: List[str],
|
| 177 |
+
num_features: int = 10,
|
| 178 |
+
) -> List[Dict[str, Any]]:
|
| 179 |
+
"""Explain multiple texts.
|
| 180 |
+
|
| 181 |
+
Args:
|
| 182 |
+
texts: List of texts
|
| 183 |
+
num_features: Number of features per explanation
|
| 184 |
+
|
| 185 |
+
Returns:
|
| 186 |
+
List of explanation dictionaries
|
| 187 |
+
"""
|
| 188 |
+
results = []
|
| 189 |
+
|
| 190 |
+
for text in texts:
|
| 191 |
+
explanation = self.explain(text, num_features=num_features)
|
| 192 |
+
|
| 193 |
+
result = {
|
| 194 |
+
"text": text,
|
| 195 |
+
"predicted_class": self.class_names[
|
| 196 |
+
explanation.available_labels()[0]
|
| 197 |
+
],
|
| 198 |
+
"explanations": {},
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
for label in explanation.available_labels():
|
| 202 |
+
result["explanations"][self.class_names[label]] = {
|
| 203 |
+
word: weight
|
| 204 |
+
for word, weight in explanation.as_list(label=label)
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
results.append(result)
|
| 208 |
+
|
| 209 |
+
return results
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class SegmentLevelLIME:
|
| 213 |
+
"""LIME with Myanmar-specific segmentation."""
|
| 214 |
+
|
| 215 |
+
def __init__(
|
| 216 |
+
self,
|
| 217 |
+
model,
|
| 218 |
+
tokenizer,
|
| 219 |
+
segment_syllables: bool = True,
|
| 220 |
+
):
|
| 221 |
+
"""
|
| 222 |
+
Args:
|
| 223 |
+
model: PyTorch model
|
| 224 |
+
tokenizer: Tokenizer
|
| 225 |
+
segment_syllables: Segment by Myanmar syllables
|
| 226 |
+
"""
|
| 227 |
+
self.model = model
|
| 228 |
+
self.tokenizer = tokenizer
|
| 229 |
+
self.segment_syllables = segment_syllables
|
| 230 |
+
|
| 231 |
+
def _segment_text(self, text: str) -> List[str]:
|
| 232 |
+
"""Segment text into interpretable units.
|
| 233 |
+
|
| 234 |
+
For Myanmar, this can be syllable or word level.
|
| 235 |
+
"""
|
| 236 |
+
if self.segment_syllables:
|
| 237 |
+
# Simple syllable segmentation
|
| 238 |
+
# Myanmar syllables end with vowel markers or consonants
|
| 239 |
+
segments = []
|
| 240 |
+
current = ""
|
| 241 |
+
|
| 242 |
+
for char in text:
|
| 243 |
+
current += char
|
| 244 |
+
# Check for syllable boundary (simplified)
|
| 245 |
+
if char in "း့်ှင်း":
|
| 246 |
+
segments.append(current)
|
| 247 |
+
current = ""
|
| 248 |
+
|
| 249 |
+
if current:
|
| 250 |
+
segments.append(current)
|
| 251 |
+
|
| 252 |
+
return segments if segments else [text]
|
| 253 |
+
else:
|
| 254 |
+
return text.split()
|
| 255 |
+
|
| 256 |
+
def _join_segments(self, segments: List[str]) -> str:
|
| 257 |
+
"""Join segments back to text."""
|
| 258 |
+
return "".join(segments)
|
| 259 |
+
|
| 260 |
+
def explain(
|
| 261 |
+
self,
|
| 262 |
+
text: str,
|
| 263 |
+
num_samples: int = 1000,
|
| 264 |
+
) -> Dict[str, Any]:
|
| 265 |
+
"""Explain text with syllable-level segmentation.
|
| 266 |
+
|
| 267 |
+
Args:
|
| 268 |
+
text: Myanmar text
|
| 269 |
+
num_samples: Number of LIME samples
|
| 270 |
+
|
| 271 |
+
Returns:
|
| 272 |
+
Explanation dictionary
|
| 273 |
+
"""
|
| 274 |
+
segments = self._segment_text(text)
|
| 275 |
+
|
| 276 |
+
logger.info(f"Segmented into {len(segments)} units")
|
| 277 |
+
|
| 278 |
+
# Use standard LIME with segmented text
|
| 279 |
+
base_explainer = ThankingLIMEExplainer(
|
| 280 |
+
self.model,
|
| 281 |
+
self.tokenizer,
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
explanation = base_explainer.explain(
|
| 285 |
+
text,
|
| 286 |
+
num_features=len(segments),
|
| 287 |
+
num_samples=num_samples,
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
return {
|
| 291 |
+
"text": text,
|
| 292 |
+
"segments": segments,
|
| 293 |
+
"explanation": explanation,
|
| 294 |
+
"word_importance": base_explainer.get_word_importance(text),
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def create_lime_explainer(
|
| 299 |
+
model,
|
| 300 |
+
tokenizer,
|
| 301 |
+
class_names: Optional[List[str]] = None,
|
| 302 |
+
) -> ThankingLIMEExplainer:
|
| 303 |
+
"""Factory function to create LIME explainer."""
|
| 304 |
+
return ThankingLIMEExplainer(
|
| 305 |
+
model=model,
|
| 306 |
+
tokenizer=tokenizer,
|
| 307 |
+
class_names=class_names,
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
if __name__ == "__main__":
|
| 312 |
+
print("ThankingLIMEExplainer loaded")
|
| 313 |
+
print("Use create_lime_explainer() to create an explainer")
|
xai/shap_explainer.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SHAP explainer for Myanmar Ghost model.
|
| 2 |
+
|
| 3 |
+
Uses SHAP (SHapley Additive exPlanations) to explain
|
| 4 |
+
individual predictions and word importance.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import shap
|
| 13 |
+
import torch
|
| 14 |
+
from tqdm import tqdm
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ThankingSHAPExplainer:
|
| 20 |
+
"""SHAP-based explainer for Myanmar text classification."""
|
| 21 |
+
|
| 22 |
+
def __init__(
|
| 23 |
+
self,
|
| 24 |
+
model,
|
| 25 |
+
tokenizer,
|
| 26 |
+
background_size: int = 100,
|
| 27 |
+
device: str = "cuda" if torch.cuda.is_available() else "cpu",
|
| 28 |
+
):
|
| 29 |
+
"""
|
| 30 |
+
Args:
|
| 31 |
+
model: PyTorch model or HuggingFace model
|
| 32 |
+
tokenizer: Tokenizer for the model
|
| 33 |
+
background_size: Number of background samples for SHAP
|
| 34 |
+
device: Device to run on
|
| 35 |
+
"""
|
| 36 |
+
self.model = model
|
| 37 |
+
self.tokenizer = tokenizer
|
| 38 |
+
self.background_size = background_size
|
| 39 |
+
self.device = device
|
| 40 |
+
|
| 41 |
+
self.model.to(device)
|
| 42 |
+
self.model.eval()
|
| 43 |
+
|
| 44 |
+
self.explainer = None
|
| 45 |
+
self.background_data = None
|
| 46 |
+
|
| 47 |
+
def _get_tokenizer(self):
|
| 48 |
+
"""Get the tokenizer, handling both HF and custom tokenizers."""
|
| 49 |
+
if hasattr(self.tokenizer, "__call__"):
|
| 50 |
+
return self.tokenizer
|
| 51 |
+
return self.tokenizer.encode
|
| 52 |
+
|
| 53 |
+
def _predict(self, texts: Union[List[str], np.ndarray]) -> np.ndarray:
|
| 54 |
+
"""Model prediction function for SHAP."""
|
| 55 |
+
if isinstance(texts, np.ndarray):
|
| 56 |
+
texts = texts.tolist()
|
| 57 |
+
|
| 58 |
+
# Tokenize
|
| 59 |
+
if hasattr(self.tokenizer, "batch_encode_plus"):
|
| 60 |
+
encoding = self.tokenizer.batch_encode_plus(
|
| 61 |
+
texts,
|
| 62 |
+
padding=True,
|
| 63 |
+
truncation=True,
|
| 64 |
+
max_length=128,
|
| 65 |
+
return_tensors="pt",
|
| 66 |
+
)
|
| 67 |
+
input_ids = encoding["input_ids"].to(self.device)
|
| 68 |
+
attention_mask = encoding["attention_mask"].to(self.device)
|
| 69 |
+
else:
|
| 70 |
+
input_ids = torch.tensor(
|
| 71 |
+
[self.tokenizer.encode(t) for t in texts]
|
| 72 |
+
).to(self.device)
|
| 73 |
+
attention_mask = (input_ids != 0).long().to(self.device)
|
| 74 |
+
|
| 75 |
+
with torch.no_grad():
|
| 76 |
+
outputs = self.model(input_ids, attention_mask)
|
| 77 |
+
|
| 78 |
+
if hasattr(outputs, "logits"):
|
| 79 |
+
logits = outputs.logits
|
| 80 |
+
else:
|
| 81 |
+
logits = outputs
|
| 82 |
+
|
| 83 |
+
probs = torch.softmax(logits, dim=-1).cpu().numpy()
|
| 84 |
+
|
| 85 |
+
return probs
|
| 86 |
+
|
| 87 |
+
def fit_background(
|
| 88 |
+
self,
|
| 89 |
+
background_texts: List[str],
|
| 90 |
+
) -> None:
|
| 91 |
+
"""Fit background distribution for SHAP.
|
| 92 |
+
|
| 93 |
+
Args:
|
| 94 |
+
background_texts: List of texts to use as background
|
| 95 |
+
"""
|
| 96 |
+
logger.info(f"Fitting SHAP background with {len(background_texts)} samples")
|
| 97 |
+
|
| 98 |
+
# Sample background if too large
|
| 99 |
+
if len(background_texts) > self.background_size:
|
| 100 |
+
indices = np.random.choice(
|
| 101 |
+
len(background_texts),
|
| 102 |
+
self.background_size,
|
| 103 |
+
replace=False,
|
| 104 |
+
)
|
| 105 |
+
background_texts = [background_texts[i] for i in indices]
|
| 106 |
+
|
| 107 |
+
self.background_data = background_texts
|
| 108 |
+
|
| 109 |
+
# Create SHAP explainer
|
| 110 |
+
self.explainer = shap.Explainer(
|
| 111 |
+
self._predict,
|
| 112 |
+
self.tokenizer,
|
| 113 |
+
output_names=["negative", "neutral", "positive", "sarcastic"],
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# Calculate background values
|
| 117 |
+
logger.info("Computing SHAP values for background...")
|
| 118 |
+
self.explainer(background_texts[:min(10, len(background_texts))])
|
| 119 |
+
|
| 120 |
+
logger.info("Background fitting complete")
|
| 121 |
+
|
| 122 |
+
def explain(
|
| 123 |
+
self,
|
| 124 |
+
text: str,
|
| 125 |
+
num_samples: int = 100,
|
| 126 |
+
output_names: Optional[List[str]] = None,
|
| 127 |
+
) -> shap.Explanation:
|
| 128 |
+
"""Explain a single text.
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
text: Myanmar text to explain
|
| 132 |
+
num_samples: Number of Monte Carlo samples
|
| 133 |
+
output_names: Names for output classes
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
SHAP Explanation object
|
| 137 |
+
"""
|
| 138 |
+
if self.explainer is None:
|
| 139 |
+
logger.warning("No background data. Using default explainer.")
|
| 140 |
+
self.explainer = shap.Explainer(
|
| 141 |
+
self._predict,
|
| 142 |
+
self.tokenizer,
|
| 143 |
+
output_names=output_names or ["negative", "neutral", "positive", "sarcastic"],
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
logger.info(f"Explaining text: {text[:50]}...")
|
| 147 |
+
|
| 148 |
+
explainer = shap.Explainer(
|
| 149 |
+
self._predict,
|
| 150 |
+
self.tokenizer,
|
| 151 |
+
output_names=output_names,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
shap_values = explainer([text])
|
| 155 |
+
|
| 156 |
+
return shap_values
|
| 157 |
+
|
| 158 |
+
def explain_batch(
|
| 159 |
+
self,
|
| 160 |
+
texts: List[str],
|
| 161 |
+
output_names: Optional[List[str]] = None,
|
| 162 |
+
) -> List[shap.Explanation]:
|
| 163 |
+
"""Explain multiple texts.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
texts: List of Myanmar texts
|
| 167 |
+
output_names: Names for output classes
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
List of SHAP Explanation objects
|
| 171 |
+
"""
|
| 172 |
+
if output_names is None:
|
| 173 |
+
output_names = ["negative", "neutral", "positive", "sarcastic"]
|
| 174 |
+
|
| 175 |
+
explainer = shap.Explainer(
|
| 176 |
+
self._predict,
|
| 177 |
+
self.tokenizer,
|
| 178 |
+
output_names=output_names,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
explanations = []
|
| 182 |
+
for text in tqdm(texts, desc="Explaining texts"):
|
| 183 |
+
exp = explainer([text])
|
| 184 |
+
explanations.append(exp)
|
| 185 |
+
|
| 186 |
+
return explanations
|
| 187 |
+
|
| 188 |
+
def get_word_importance(
|
| 189 |
+
self,
|
| 190 |
+
text: str,
|
| 191 |
+
class_index: int = 2, # positive by default
|
| 192 |
+
) -> List[Tuple[str, float]]:
|
| 193 |
+
"""Get word importance scores for a specific class.
|
| 194 |
+
|
| 195 |
+
Args:
|
| 196 |
+
text: Myanmar text
|
| 197 |
+
class_index: Class index to explain
|
| 198 |
+
|
| 199 |
+
Returns:
|
| 200 |
+
List of (word, importance) tuples
|
| 201 |
+
"""
|
| 202 |
+
explanation = self.explain(text)
|
| 203 |
+
|
| 204 |
+
# Get tokens and their SHAP values
|
| 205 |
+
tokens = self.tokenizer.tokenize(text)
|
| 206 |
+
shap_vals = explanation.values[0, :, class_index]
|
| 207 |
+
|
| 208 |
+
# Handle tokenization differences
|
| 209 |
+
if len(shap_vals) < len(tokens):
|
| 210 |
+
# Pad if needed
|
| 211 |
+
shap_vals = np.pad(
|
| 212 |
+
shap_vals,
|
| 213 |
+
(0, len(tokens) - len(shap_vals)),
|
| 214 |
+
constant_values=0,
|
| 215 |
+
)
|
| 216 |
+
elif len(shap_vals) > len(tokens):
|
| 217 |
+
tokens = tokens + ["[PAD]"] * (len(shap_vals) - len(tokens))
|
| 218 |
+
|
| 219 |
+
# Create word-score pairs
|
| 220 |
+
word_importance = list(zip(tokens, shap_vals.tolist()))
|
| 221 |
+
|
| 222 |
+
# Sort by absolute importance
|
| 223 |
+
word_importance.sort(key=lambda x: abs(x[1]), reverse=True)
|
| 224 |
+
|
| 225 |
+
return word_importance
|
| 226 |
+
|
| 227 |
+
def visualize_text(
|
| 228 |
+
self,
|
| 229 |
+
explanation: shap.Explanation,
|
| 230 |
+
output_path: Optional[str] = None,
|
| 231 |
+
) -> None:
|
| 232 |
+
"""Visualize SHAP explanation as text.
|
| 233 |
+
|
| 234 |
+
Args:
|
| 235 |
+
explanation: SHAP explanation
|
| 236 |
+
output_path: Optional path to save visualization
|
| 237 |
+
"""
|
| 238 |
+
text = explanation.data[0] if hasattr(explanation, "data") else ""
|
| 239 |
+
|
| 240 |
+
output = f"\n{'='*60}\n"
|
| 241 |
+
output += f"Text: {text}\n"
|
| 242 |
+
output += f"{'='*60}\n"
|
| 243 |
+
|
| 244 |
+
# Get top features for each class
|
| 245 |
+
for i, class_name in enumerate(explanation.output_names):
|
| 246 |
+
values = explanation.values[0, :, i]
|
| 247 |
+
|
| 248 |
+
# Get top 5 words
|
| 249 |
+
top_indices = np.argsort(np.abs(values))[-5:][::-1]
|
| 250 |
+
|
| 251 |
+
output += f"\nClass: {class_name}\n"
|
| 252 |
+
output += "-" * 40 + "\n"
|
| 253 |
+
|
| 254 |
+
for idx in top_indices:
|
| 255 |
+
if idx < len(text.split()):
|
| 256 |
+
word = text.split()[idx]
|
| 257 |
+
output += f" {word}: {values[idx]:.4f}\n"
|
| 258 |
+
|
| 259 |
+
print(output)
|
| 260 |
+
|
| 261 |
+
if output_path:
|
| 262 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 263 |
+
f.write(output)
|
| 264 |
+
logger.info(f"Visualization saved to {output_path}")
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class ThankingSHAPValues:
|
| 268 |
+
"""Compute SHAP values for thanking expression analysis."""
|
| 269 |
+
|
| 270 |
+
def __init__(
|
| 271 |
+
self,
|
| 272 |
+
model,
|
| 273 |
+
tokenizer,
|
| 274 |
+
class_names: List[str] = None,
|
| 275 |
+
):
|
| 276 |
+
self.model = model
|
| 277 |
+
self.tokenizer = tokenizer
|
| 278 |
+
self.class_names = class_names or [
|
| 279 |
+
"genuine", "sarcastic", "complaining", "neutral"
|
| 280 |
+
]
|
| 281 |
+
|
| 282 |
+
def compute_feature_importance(
|
| 283 |
+
self,
|
| 284 |
+
texts: List[str],
|
| 285 |
+
feature_names: List[str],
|
| 286 |
+
) -> Dict[str, float]:
|
| 287 |
+
"""Compute SHAP-based feature importance.
|
| 288 |
+
|
| 289 |
+
Args:
|
| 290 |
+
texts: List of texts
|
| 291 |
+
feature_names: Names of features to analyze
|
| 292 |
+
|
| 293 |
+
Returns:
|
| 294 |
+
Dictionary of feature importance scores
|
| 295 |
+
"""
|
| 296 |
+
import shap
|
| 297 |
+
|
| 298 |
+
def predict_proba(texts: List[str]) -> np.ndarray:
|
| 299 |
+
encoding = self.tokenizer.batch_encode_plus(
|
| 300 |
+
texts,
|
| 301 |
+
padding=True,
|
| 302 |
+
truncation=True,
|
| 303 |
+
max_length=128,
|
| 304 |
+
return_tensors="pt",
|
| 305 |
+
)
|
| 306 |
+
|
| 307 |
+
with torch.no_grad():
|
| 308 |
+
outputs = self.model(
|
| 309 |
+
encoding["input_ids"],
|
| 310 |
+
encoding["attention_mask"],
|
| 311 |
+
)
|
| 312 |
+
probs = torch.softmax(outputs.logits, dim=-1)
|
| 313 |
+
return probs.numpy()
|
| 314 |
+
|
| 315 |
+
# Create simple background
|
| 316 |
+
background = texts[:min(20, len(texts))]
|
| 317 |
+
|
| 318 |
+
explainer = shap.Explainer(predict_proba, background)
|
| 319 |
+
shap_values = explainer(texts[:5]) # Sample for speed
|
| 320 |
+
|
| 321 |
+
# Aggregate importance
|
| 322 |
+
importance = {}
|
| 323 |
+
for i, feature in enumerate(feature_names):
|
| 324 |
+
importance[feature] = np.mean(
|
| 325 |
+
np.abs(shap_values.values[:, :, i])
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
return importance
|
| 329 |
+
|
| 330 |
+
def analyze_sentence(
|
| 331 |
+
self,
|
| 332 |
+
text: str,
|
| 333 |
+
) -> Dict[str, Any]:
|
| 334 |
+
"""Analyze a single sentence with SHAP.
|
| 335 |
+
|
| 336 |
+
Args:
|
| 337 |
+
text: Myanmar text
|
| 338 |
+
|
| 339 |
+
Returns:
|
| 340 |
+
Analysis results including word importance
|
| 341 |
+
"""
|
| 342 |
+
import shap
|
| 343 |
+
|
| 344 |
+
encoding = self.tokenizer(
|
| 345 |
+
text,
|
| 346 |
+
padding=True,
|
| 347 |
+
truncation=True,
|
| 348 |
+
max_length=128,
|
| 349 |
+
return_tensors="pt",
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
explainer = shap.Explainer(
|
| 353 |
+
lambda x: self._predict_batch(x),
|
| 354 |
+
self.tokenizer,
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
explanation = explainer([text])
|
| 358 |
+
|
| 359 |
+
# Extract word-level importance
|
| 360 |
+
tokens = self.tokenizer.convert_ids_to_tokens(
|
| 361 |
+
encoding["input_ids"][0]
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
result = {
|
| 365 |
+
"text": text,
|
| 366 |
+
"tokens": tokens,
|
| 367 |
+
"prediction": explanation.output_names[
|
| 368 |
+
np.argmax(explanation.values[0].mean(axis=1))
|
| 369 |
+
],
|
| 370 |
+
"shap_values": explanation.values[0].tolist(),
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
return result
|
| 374 |
+
|
| 375 |
+
def _predict_batch(self, texts: List[str]) -> np.ndarray:
|
| 376 |
+
"""Batch prediction for SHAP."""
|
| 377 |
+
encoding = self.tokenizer(
|
| 378 |
+
texts,
|
| 379 |
+
padding=True,
|
| 380 |
+
truncation=True,
|
| 381 |
+
max_length=128,
|
| 382 |
+
return_tensors="pt",
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
with torch.no_grad():
|
| 386 |
+
outputs = self.model(
|
| 387 |
+
encoding["input_ids"],
|
| 388 |
+
encoding["attention_mask"],
|
| 389 |
+
)
|
| 390 |
+
probs = torch.softmax(outputs.logits, dim=-1)
|
| 391 |
+
return probs.numpy()
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def create_shap_explainer(
|
| 395 |
+
model,
|
| 396 |
+
tokenizer,
|
| 397 |
+
background_texts: Optional[List[str]] = None,
|
| 398 |
+
) -> ThankingSHAPExplainer:
|
| 399 |
+
"""Factory function to create SHAP explainer."""
|
| 400 |
+
explainer = ThankingSHAPExplainer(model, tokenizer)
|
| 401 |
+
|
| 402 |
+
if background_texts:
|
| 403 |
+
explainer.fit_background(background_texts)
|
| 404 |
+
|
| 405 |
+
return explainer
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
if __name__ == "__main__":
|
| 409 |
+
print("ThankingSHAPExplainer loaded")
|
| 410 |
+
print("Use create_shap_explainer() to create an explainer")
|
xai/visualization.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Visualization utilities for XAI results."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any, Dict, List, Optional, Union
|
| 6 |
+
|
| 7 |
+
import matplotlib.pyplot as plt
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
import shap
|
| 12 |
+
HAS_SHAP = True
|
| 13 |
+
except ImportError:
|
| 14 |
+
HAS_SHAP = False
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
from IPython.display import HTML, display
|
| 18 |
+
HAS_IPYTHON = True
|
| 19 |
+
except ImportError:
|
| 20 |
+
HAS_IPYTHON = False
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class XAIVisualizer:
|
| 24 |
+
"""Visualize XAI results."""
|
| 25 |
+
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
class_names: Optional[List[str]] = None,
|
| 29 |
+
output_dir: str = "outputs/xai",
|
| 30 |
+
):
|
| 31 |
+
self.class_names = class_names or [
|
| 32 |
+
"negative", "neutral", "positive", "sarcastic"
|
| 33 |
+
]
|
| 34 |
+
self.output_dir = Path(output_dir)
|
| 35 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
|
| 37 |
+
def plot_word_importance(
|
| 38 |
+
self,
|
| 39 |
+
words: List[str],
|
| 40 |
+
importance: List[float],
|
| 41 |
+
title: str = "Word Importance",
|
| 42 |
+
output_path: Optional[str] = None,
|
| 43 |
+
figsize: tuple = (10, 6),
|
| 44 |
+
) -> plt.Figure:
|
| 45 |
+
"""Plot word importance as horizontal bar chart.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
words: List of words/tokens
|
| 49 |
+
importance: Importance scores
|
| 50 |
+
title: Plot title
|
| 51 |
+
output_path: Path to save figure
|
| 52 |
+
figsize: Figure size
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
Matplotlib figure
|
| 56 |
+
"""
|
| 57 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 58 |
+
|
| 59 |
+
# Sort by absolute importance
|
| 60 |
+
sorted_pairs = sorted(
|
| 61 |
+
zip(words, importance),
|
| 62 |
+
key=lambda x: abs(x[1]),
|
| 63 |
+
reverse=True,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
sorted_words = [p[0] for p in sorted_pairs]
|
| 67 |
+
sorted_importance = [p[1] for p in sorted_pairs]
|
| 68 |
+
|
| 69 |
+
# Color based on positive/negative
|
| 70 |
+
colors = ["green" if v > 0 else "red" for v in sorted_importance]
|
| 71 |
+
|
| 72 |
+
ax.barh(sorted_words, sorted_importance, color=colors, alpha=0.7)
|
| 73 |
+
ax.axvline(x=0, color="black", linestyle="-", linewidth=0.5)
|
| 74 |
+
ax.set_xlabel("SHAP Value")
|
| 75 |
+
ax.set_title(title)
|
| 76 |
+
ax.invert_yaxis()
|
| 77 |
+
|
| 78 |
+
plt.tight_layout()
|
| 79 |
+
|
| 80 |
+
if output_path:
|
| 81 |
+
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
| 82 |
+
|
| 83 |
+
return fig
|
| 84 |
+
|
| 85 |
+
def plot_feature_importance(
|
| 86 |
+
self,
|
| 87 |
+
features: List[str],
|
| 88 |
+
importance: List[float],
|
| 89 |
+
title: str = "Feature Importance",
|
| 90 |
+
output_path: Optional[str] = None,
|
| 91 |
+
figsize: tuple = (10, 6),
|
| 92 |
+
) -> plt.Figure:
|
| 93 |
+
"""Plot feature importance bar chart.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
features: List of feature names
|
| 97 |
+
importance: Importance scores
|
| 98 |
+
title: Plot title
|
| 99 |
+
output_path: Path to save figure
|
| 100 |
+
figsize: Figure size
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
Matplotlib figure
|
| 104 |
+
"""
|
| 105 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 106 |
+
|
| 107 |
+
# Sort by importance
|
| 108 |
+
sorted_pairs = sorted(
|
| 109 |
+
zip(features, importance),
|
| 110 |
+
key=lambda x: x[1],
|
| 111 |
+
reverse=True,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
sorted_features = [p[0] for p in sorted_pairs]
|
| 115 |
+
sorted_importance = [p[1] for p in sorted_pairs]
|
| 116 |
+
|
| 117 |
+
ax.barh(sorted_features, sorted_importance, color="steelblue", alpha=0.7)
|
| 118 |
+
ax.set_xlabel("Importance Score")
|
| 119 |
+
ax.set_title(title)
|
| 120 |
+
ax.invert_yaxis()
|
| 121 |
+
|
| 122 |
+
plt.tight_layout()
|
| 123 |
+
|
| 124 |
+
if output_path:
|
| 125 |
+
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
| 126 |
+
|
| 127 |
+
return fig
|
| 128 |
+
|
| 129 |
+
def plot_confidence_distribution(
|
| 130 |
+
self,
|
| 131 |
+
predictions: List[str],
|
| 132 |
+
confidences: List[float],
|
| 133 |
+
output_path: Optional[str] = None,
|
| 134 |
+
figsize: tuple = (10, 6),
|
| 135 |
+
) -> plt.Figure:
|
| 136 |
+
"""Plot distribution of prediction confidences.
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
predictions: Predicted classes
|
| 140 |
+
confidences: Confidence scores
|
| 141 |
+
output_path: Path to save figure
|
| 142 |
+
figsize: Figure size
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
Matplotlib figure
|
| 146 |
+
"""
|
| 147 |
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize)
|
| 148 |
+
|
| 149 |
+
# Histogram
|
| 150 |
+
ax1.hist(confidences, bins=20, alpha=0.7, color="steelblue")
|
| 151 |
+
ax1.axvline(np.mean(confidences), color="red", linestyle="--",
|
| 152 |
+
label=f"Mean: {np.mean(confidences):.3f}")
|
| 153 |
+
ax1.set_xlabel("Confidence")
|
| 154 |
+
ax1.set_ylabel("Count")
|
| 155 |
+
ax1.set_title("Confidence Distribution")
|
| 156 |
+
ax1.legend()
|
| 157 |
+
|
| 158 |
+
# By class
|
| 159 |
+
unique_classes = list(set(predictions))
|
| 160 |
+
class_confidences = {c: [] for c in unique_classes}
|
| 161 |
+
|
| 162 |
+
for pred, conf in zip(predictions, confidences):
|
| 163 |
+
class_confidences[pred].append(conf)
|
| 164 |
+
|
| 165 |
+
ax2.boxplot(
|
| 166 |
+
[class_confidences[c] for c in unique_classes],
|
| 167 |
+
labels=unique_classes,
|
| 168 |
+
)
|
| 169 |
+
ax2.set_xlabel("Class")
|
| 170 |
+
ax2.set_ylabel("Confidence")
|
| 171 |
+
ax2.set_title("Confidence by Class")
|
| 172 |
+
|
| 173 |
+
plt.tight_layout()
|
| 174 |
+
|
| 175 |
+
if output_path:
|
| 176 |
+
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
| 177 |
+
|
| 178 |
+
return fig
|
| 179 |
+
|
| 180 |
+
def plot_shap_summary(
|
| 181 |
+
self,
|
| 182 |
+
shap_values: np.ndarray,
|
| 183 |
+
features: np.ndarray,
|
| 184 |
+
feature_names: List[str],
|
| 185 |
+
output_path: Optional[str] = None,
|
| 186 |
+
figsize: tuple = (12, 8),
|
| 187 |
+
) -> plt.Figure:
|
| 188 |
+
"""Plot SHAP summary (beeswarm) plot.
|
| 189 |
+
|
| 190 |
+
Args:
|
| 191 |
+
shap_values: SHAP values array
|
| 192 |
+
features: Feature values array
|
| 193 |
+
feature_names: Names of features
|
| 194 |
+
output_path: Path to save figure
|
| 195 |
+
figsize: Figure size
|
| 196 |
+
|
| 197 |
+
Returns:
|
| 198 |
+
Matplotlib figure
|
| 199 |
+
"""
|
| 200 |
+
if not HAS_SHAP:
|
| 201 |
+
raise ImportError("shap library required for this visualization")
|
| 202 |
+
|
| 203 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 204 |
+
|
| 205 |
+
shap.summary_plot(
|
| 206 |
+
shap_values,
|
| 207 |
+
features,
|
| 208 |
+
feature_names=feature_names,
|
| 209 |
+
show=False,
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
plt.tight_layout()
|
| 213 |
+
|
| 214 |
+
if output_path:
|
| 215 |
+
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
| 216 |
+
|
| 217 |
+
return fig
|
| 218 |
+
|
| 219 |
+
def plot_comparison(
|
| 220 |
+
self,
|
| 221 |
+
explanations: Dict[str, List[Tuple[str, float]]],
|
| 222 |
+
output_path: Optional[str] = None,
|
| 223 |
+
figsize: tuple = (12, 8),
|
| 224 |
+
) -> plt.Figure:
|
| 225 |
+
"""Compare explanations across different methods or samples.
|
| 226 |
+
|
| 227 |
+
Args:
|
| 228 |
+
explanations: Dict mapping sample IDs to explanation tuples
|
| 229 |
+
output_path: Path to save figure
|
| 230 |
+
figsize: Figure size
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
Matplotlib figure
|
| 234 |
+
"""
|
| 235 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 236 |
+
|
| 237 |
+
# Get all unique words
|
| 238 |
+
all_words = set()
|
| 239 |
+
for exp in explanations.values():
|
| 240 |
+
for word, _ in exp:
|
| 241 |
+
all_words.add(word)
|
| 242 |
+
|
| 243 |
+
all_words = list(all_words)[:20] # Limit to top 20
|
| 244 |
+
|
| 245 |
+
# Create matrix
|
| 246 |
+
matrix = []
|
| 247 |
+
for sample_id, exp in explanations.items():
|
| 248 |
+
exp_dict = dict(exp)
|
| 249 |
+
row = [exp_dict.get(w, 0) for w in all_words]
|
| 250 |
+
matrix.append(row)
|
| 251 |
+
|
| 252 |
+
matrix = np.array(matrix)
|
| 253 |
+
|
| 254 |
+
# Plot heatmap
|
| 255 |
+
im = ax.imshow(matrix, cmap="RdBu_r", aspect="auto")
|
| 256 |
+
ax.set_xticks(range(len(all_words)))
|
| 257 |
+
ax.set_xticklabels(all_words, rotation=45, ha="right")
|
| 258 |
+
ax.set_yticks(range(len(explanations)))
|
| 259 |
+
ax.set_yticklabels(list(explanations.keys()))
|
| 260 |
+
ax.set_title("Explanation Comparison")
|
| 261 |
+
|
| 262 |
+
plt.colorbar(im, ax=ax, label="Importance")
|
| 263 |
+
|
| 264 |
+
plt.tight_layout()
|
| 265 |
+
|
| 266 |
+
if output_path:
|
| 267 |
+
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
| 268 |
+
|
| 269 |
+
return fig
|
| 270 |
+
|
| 271 |
+
def save_explanations(
|
| 272 |
+
self,
|
| 273 |
+
explanations: List[Dict[str, Any]],
|
| 274 |
+
output_path: str,
|
| 275 |
+
) -> None:
|
| 276 |
+
"""Save explanations to JSON file.
|
| 277 |
+
|
| 278 |
+
Args:
|
| 279 |
+
explanations: List of explanation dictionaries
|
| 280 |
+
output_path: Path to save JSON
|
| 281 |
+
"""
|
| 282 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 283 |
+
json.dump(explanations, f, indent=2, ensure_ascii=False)
|
| 284 |
+
|
| 285 |
+
def generate_html_report(
|
| 286 |
+
self,
|
| 287 |
+
explanations: List[Dict[str, Any]],
|
| 288 |
+
output_path: str,
|
| 289 |
+
) -> None:
|
| 290 |
+
"""Generate HTML report of explanations.
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
explanations: List of explanation dictionaries
|
| 294 |
+
output_path: Path to save HTML
|
| 295 |
+
"""
|
| 296 |
+
html = """
|
| 297 |
+
<!DOCTYPE html>
|
| 298 |
+
<html>
|
| 299 |
+
<head>
|
| 300 |
+
<title>XAI Explanation Report</title>
|
| 301 |
+
<style>
|
| 302 |
+
body { font-family: Arial, sans-serif; margin: 20px; }
|
| 303 |
+
.explanation { border: 1px solid #ccc; padding: 15px; margin: 10px 0; border-radius: 5px; }
|
| 304 |
+
.text { font-size: 18px; margin-bottom: 10px; }
|
| 305 |
+
.prediction { font-weight: bold; color: #2196F3; }
|
| 306 |
+
.word { display: inline-block; padding: 2px 5px; margin: 2px; border-radius: 3px; }
|
| 307 |
+
.positive { background-color: #c8e6c9; }
|
| 308 |
+
.negative { background-color: #ffcdd2; }
|
| 309 |
+
.neutral { background-color: #e0e0e0; }
|
| 310 |
+
table { border-collapse: collapse; width: 100%; }
|
| 311 |
+
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
| 312 |
+
th { background-color: #f5f5f5; }
|
| 313 |
+
</style>
|
| 314 |
+
</head>
|
| 315 |
+
<body>
|
| 316 |
+
<h1>XAI Explanation Report</h1>
|
| 317 |
+
<p>Total explanations: """ + str(len(explanations)) + """</p>
|
| 318 |
+
"""
|
| 319 |
+
|
| 320 |
+
for i, exp in enumerate(explanations):
|
| 321 |
+
html += f"""
|
| 322 |
+
<div class="explanation">
|
| 323 |
+
<div class="text">{exp.get('text', 'N/A')}</div>
|
| 324 |
+
<div class="prediction">Predicted: {exp.get('predicted_class', 'N/A')}</div>
|
| 325 |
+
<div>
|
| 326 |
+
"""
|
| 327 |
+
|
| 328 |
+
for word, weight in exp.get("word_importance", []):
|
| 329 |
+
color_class = "positive" if weight > 0 else "negative"
|
| 330 |
+
html += f'<span class="word {color_class}">{word}: {weight:.3f}</span>'
|
| 331 |
+
|
| 332 |
+
html += """
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
"""
|
| 336 |
+
|
| 337 |
+
html += """
|
| 338 |
+
</body>
|
| 339 |
+
</html>
|
| 340 |
+
"""
|
| 341 |
+
|
| 342 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 343 |
+
f.write(html)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def create_visualizer(
|
| 347 |
+
class_names: Optional[List[str]] = None,
|
| 348 |
+
output_dir: str = "outputs/xai",
|
| 349 |
+
) -> XAIVisualizer:
|
| 350 |
+
"""Factory function to create XAI visualizer."""
|
| 351 |
+
return XAIVisualizer(
|
| 352 |
+
class_names=class_names,
|
| 353 |
+
output_dir=output_dir,
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
if __name__ == "__main__":
|
| 358 |
+
visualizer = create_visualizer()
|
| 359 |
+
print("XAIVisualizer loaded")
|
| 360 |
+
print(f"Available methods: plot_word_importance, plot_feature_importance, etc.")
|