amkyawdev commited on
Commit
cfb5e7f
·
verified ·
1 Parent(s): 75e3fd1

Add source code

Browse files
__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.")