arabic-native-classifier / scripts /train_classifier_compare.py
Utkarsh187's picture
Final change
508969a
Raw History Blame Contribute Delete
18.7 kB
"""
Train and compare: MLP (baseline), MLP+channel attention (prosody emphasis), and ensemble.
Reports metrics for each so you can see which gives higher accuracy/F1.
"""
import os
import sys
import random
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pandas as pd
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.utils.class_weight import compute_class_weight
from sklearn.decomposition import PCA # --- NEW: Import PCA ---
from sklearn.metrics import (
accuracy_score,
f1_score,
precision_score,
recall_score,
confusion_matrix,
)
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from model import NativityClassifier, NativityClassifierWithAttention
SEED = 42
MANIFEST_PATH = "manifests/final_training_manifest.csv"
MODEL_SAVE_DIR = "models"
EPOCHS = 100
BATCH_SIZE = 32
# --- ANTI-OVERFITTING: Hyperparameter adjustments ---
LR = 1e-3 # Slowed down from 1e-3
WEIGHT_DECAY = 1e-4 # Punish extreme weights (up from 5e-4)
PATIENCE = 15 # Stop earlier if it starts memorizing
MIN_EPOCHS = 40
PCA_COMPONENTS = 256 # Compress 1000+ dims down to top 64
HIDDEN_DIMS = (256, 128) # Shrunk from (384, 256, 128)
DROPOUT = 0.4
# ----------------------------------------------------
OUTPUT_DIM = 2
# Lower threshold for Class 1 (Non-Native) to boost recall on imbalanced data
EVAL_THRESHOLD = 0.50
# SMOTE oversamples minority (Non-Native) in embedding space; already using weighted CE
USE_SMOTE = False
SMOTE_K_NEIGHBORS = 5
# Threshold sweep on val to pick best (don't tune on test)
THRESHOLD_CANDIDATES = [0.25, 0.28, 0.30, 0.32, 0.35, 0.40]
BEST_THRESHOLD_PATH = os.path.join(MODEL_SAVE_DIR, "best_threshold.txt")
MIN_LR = 1e-6
def set_seed(seed):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
else:
torch.backends.cudnn.benchmark = True
class EmbeddingDataset(torch.utils.data.Dataset):
def __init__(
self,
df,
embedding_path_col="embedding_path",
label_col="label_idx",
l2_norm=True,
):
self.paths = [str(p).replace("\\", "/") for p in df[embedding_path_col].tolist()]
self.labels = df[label_col].values
self.l2_norm = l2_norm
def __len__(self):
return len(self.labels)
def __getitem__(self, idx):
emb = np.load(self.paths[idx]).astype(np.float32)
if self.l2_norm:
n = np.linalg.norm(emb)
if n > 1e-8:
emb = emb / n
return torch.tensor(emb, dtype=torch.float32), torch.tensor(
self.labels[idx], dtype=torch.long
)
def train_model(
model,
train_loader,
val_loader,
criterion,
device,
save_path,
epochs=EPOCHS,
patience=PATIENCE,
min_epochs=MIN_EPOCHS,
):
optimizer = optim.AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode="max", factor=0.5, patience=5, min_lr=MIN_LR
)
best_f1 = 0.0
patience_counter = 0
history = {
"train_loss": [],
"val_loss": [],
"train_acc": [],
"val_acc": [],
"val_f1": [],
}
for epoch in range(epochs):
model.train()
train_loss_sum = 0.0
train_correct = 0
train_total = 0
for batch_x, batch_y in train_loader:
batch_x, batch_y = batch_x.to(device), batch_y.to(device)
# --- NEW: Inject Gaussian Noise during training to prevent memorization ---
if model.training:
noise = torch.randn_like(batch_x) * 0.02
batch_x = batch_x + noise
# ------------------------------------------------------------------------
optimizer.zero_grad()
logits = model(batch_x)
loss = criterion(logits, batch_y)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
train_loss_sum += loss.item() * batch_x.size(0)
preds = logits.argmax(dim=1)
train_correct += (preds == batch_y).sum().item()
train_total += batch_x.size(0)
train_loss = train_loss_sum / max(1, train_total)
train_acc = train_correct / max(1, train_total)
# Validation
model.eval()
val_loss_sum = 0.0
val_correct = 0
val_total = 0
preds_list = []
labels_list = []
with torch.no_grad():
for batch_x, batch_y in val_loader:
batch_x, batch_y = batch_x.to(device), batch_y.to(device)
logits = model(batch_x)
loss = criterion(logits, batch_y)
val_loss_sum += loss.item() * batch_x.size(0)
preds = logits.argmax(dim=1)
val_correct += (preds == batch_y).sum().item()
val_total += batch_x.size(0)
preds_list.extend(preds.cpu().numpy())
labels_list.extend(batch_y.cpu().numpy())
val_loss = val_loss_sum / max(1, val_total)
val_acc = val_correct / max(1, val_total)
val_f1 = f1_score(
np.array(labels_list),
np.array(preds_list),
average="macro",
zero_division=0,
)
history["train_loss"].append(train_loss)
history["val_loss"].append(val_loss)
history["train_acc"].append(train_acc)
history["val_acc"].append(val_acc)
history["val_f1"].append(val_f1)
scheduler.step(val_f1)
if val_f1 > best_f1:
best_f1 = val_f1
patience_counter = 0
torch.save(model.state_dict(), save_path)
else:
patience_counter += 1
if patience_counter >= patience and (epoch + 1) >= min_epochs:
print(f"Early stopping triggered at epoch {epoch + 1}")
break
return best_f1, history
def eval_model(model, val_loader, device, threshold=0.35):
model.eval()
preds, probs_list = [], []
with torch.no_grad():
for batch_x, batch_y in val_loader:
batch_x = batch_x.to(device)
logits = model(batch_x)
probs = torch.softmax(logits, dim=1).cpu().numpy()
probs_list.append(probs)
batch_preds = (probs[:, 1] >= threshold).astype(int)
preds.extend(batch_preds)
probs = np.vstack(probs_list)
return np.array(preds), probs
def plot_history(history, model_name, out_dir=MODEL_SAVE_DIR):
os.makedirs(out_dir, exist_ok=True)
epochs = list(range(1, len(history["train_loss"]) + 1))
plt.figure(figsize=(8, 5))
plt.plot(epochs, history["train_loss"], label="train loss", marker="o")
plt.plot(epochs, history["val_loss"], label="val loss", marker="o")
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.title(f"{model_name} - Loss per epoch")
plt.legend()
plt.grid(True)
out_path = os.path.join(out_dir, f"{model_name}_loss.png")
plt.tight_layout()
plt.savefig(out_path)
plt.close()
plt.figure(figsize=(8, 5))
plt.plot(epochs, history["train_acc"], label="train acc", marker="o")
plt.plot(epochs, history["val_acc"], label="val acc", marker="o")
plt.xlabel("Epoch")
plt.ylabel("Accuracy")
plt.title(f"{model_name} - Accuracy per epoch")
plt.legend()
plt.grid(True)
out_path2 = os.path.join(out_dir, f"{model_name}_acc.png")
plt.tight_layout()
plt.savefig(out_path2)
plt.close()
def main():
set_seed(SEED)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
os.makedirs(MODEL_SAVE_DIR, exist_ok=True)
df = pd.read_csv(MANIFEST_PATH)
# Normalize paths and keep only rows whose embedding file exists
df["embedding_path"] = df["embedding_path"].str.replace("\\\\", "/", regex=False)
before = len(df)
df = df[df["embedding_path"].apply(os.path.isfile)].copy()
if len(df) < before:
print(f"Dropped {before - len(df)} rows with missing embedding files (using {len(df)}).")
if len(df) == 0:
raise FileNotFoundError("No embedding files found. Run extract_embeddings.py first.")
le = LabelEncoder()
df["label_idx"] = le.fit_transform(df["label"])
speaker_df = df.groupby("original_id")["label_idx"].first().reset_index()
train_speakers, val_speakers = train_test_split(
speaker_df["original_id"],
test_size=0.2,
stratify=speaker_df["label_idx"],
random_state=SEED,
)
train_df = df[df["original_id"].isin(train_speakers)].copy()
val_df = df[df["original_id"].isin(val_speakers)].copy()
train_set = EmbeddingDataset(train_df, l2_norm=True)
val_set = EmbeddingDataset(val_df, l2_norm=True)
# =========================================================================
# --- NEW: PCA Dimensionality Reduction (Fitted ONLY on training data) ---
# =========================================================================
print(f"\nApplying PCA to crush dimensions down to {PCA_COMPONENTS}...")
# 1. Extract raw numpy arrays
X_train_raw = np.stack([train_set[i][0].numpy() for i in range(len(train_set))])
y_train_raw = np.array([train_set[i][1].item() for i in range(len(train_set))])
X_val_raw = np.stack([val_set[i][0].numpy() for i in range(len(val_set))])
y_val_raw = np.array([val_set[i][1].item() for i in range(len(val_set))])
# 2. Fit PCA strictly on training data
pca = PCA(n_components=PCA_COMPONENTS, random_state=SEED)
X_train_pca = pca.fit_transform(X_train_raw)
X_val_pca = pca.transform(X_val_raw)
# 3. Overwrite Datasets with compressed tensors
train_set = TensorDataset(
torch.tensor(X_train_pca, dtype=torch.float32),
torch.tensor(y_train_raw, dtype=torch.long),
)
val_set = TensorDataset(
torch.tensor(X_val_pca, dtype=torch.float32),
torch.tensor(y_val_raw, dtype=torch.long),
)
# 4. Override input_dim for the PyTorch models
input_dim = PCA_COMPONENTS
print(f"New Model Input Dimension: {input_dim}")
# =========================================================================
# Optional SMOTE (Now operating safely on the 64-dimensional PCA space)
if USE_SMOTE:
try:
from imblearn.over_sampling import SMOTE
X_train = np.stack([train_set[i][0].numpy() for i in range(len(train_set))])
y_train = np.array([train_set[i][1].item() for i in range(len(train_set))])
n_minority = int((y_train == 1).sum())
k = min(SMOTE_K_NEIGHBORS, n_minority - 1) if n_minority > 1 else 1
if k >= 1 and n_minority < (y_train == 0).sum():
smote = SMOTE(k_neighbors=k, random_state=SEED)
X_res, y_res = smote.fit_resample(X_train, y_train)
train_set = TensorDataset(
torch.tensor(X_res, dtype=torch.float32),
torch.tensor(y_res, dtype=torch.long),
)
print(
f"SMOTE: {len(y_train)} -> {len(y_res)} samples (Non-Native oversampled)"
)
else:
print("SMOTE: skipped (minority too small or already balanced)")
except Exception as e:
print(f"SMOTE skipped: {e}")
train_loader = DataLoader(
train_set, batch_size=BATCH_SIZE, shuffle=True, drop_last=True, num_workers=0
)
val_loader = DataLoader(
val_set, batch_size=BATCH_SIZE, shuffle=False, num_workers=0
)
weights = compute_class_weight(
"balanced", classes=np.unique(df["label_idx"]), y=df["label_idx"]
)
class_weights = torch.tensor(weights, dtype=torch.float32).to(device)
# --- Label Smoothing included in the loss function ---
criterion = nn.CrossEntropyLoss(weight=class_weights, label_smoothing=0.15)
y_true = np.concatenate(
[val_set[i][1].numpy().reshape(1) for i in range(len(val_set))]
)
# ---- 1. Train MLP (baseline) ----
print("\n" + "=" * 60)
print("1. Training MLP (baseline)")
print("=" * 60)
model_mlp = NativityClassifier(
input_dim=input_dim,
hidden_dims=HIDDEN_DIMS,
output_dim=OUTPUT_DIM,
dropout=DROPOUT,
).to(device)
best_f1_mlp, hist_mlp = train_model(
model_mlp,
train_loader,
val_loader,
criterion,
device,
os.path.join(MODEL_SAVE_DIR, "nativity_model.pth"),
)
try:
plot_history(hist_mlp, "mlp_baseline", MODEL_SAVE_DIR)
print(f"Saved training plots for mlp_baseline in {MODEL_SAVE_DIR}")
except Exception as e:
print(f"Plotting mlp_baseline failed: {e}")
# ---- 2. Train MLP + channel attention (prosody emphasis) ----
print("\n2. Training MLP + channel attention (prosody emphasis)")
print("=" * 60)
model_att = NativityClassifierWithAttention(
input_dim=input_dim,
hidden_dims=HIDDEN_DIMS,
output_dim=OUTPUT_DIM,
dropout=DROPOUT,
final_dropout=0.75, # <--- Increased to 0.75 for safety
reduction_ratio=8,
).to(device)
best_f1_att, hist_att = train_model(
model_att,
train_loader,
val_loader,
criterion,
device,
os.path.join(MODEL_SAVE_DIR, "nativity_model_attention.pth"),
)
try:
plot_history(hist_att, "mlp_attention", MODEL_SAVE_DIR)
print(f"Saved training plots for mlp_attention in {MODEL_SAVE_DIR}")
except Exception as e:
print(f"Plotting mlp_attention failed: {e}")
# ---- 3. LightGBM on same train/val embeddings ----
print("\n3. Training LightGBM on embedding vectors")
print("=" * 60)
X_train = np.stack([train_set[i][0].numpy() for i in range(len(train_set))])
y_train = np.array([train_set[i][1].item() for i in range(len(train_set))])
X_val = np.stack([val_set[i][0].numpy() for i in range(len(val_set))])
try:
import lightgbm as lgb
lgb_train = lgb.Dataset(X_train, y_train)
lgb_val = lgb.Dataset(X_val, reference=lgb_train)
params = {
"objective": "multiclass",
"num_class": 2,
"metric": "multi_logloss",
"num_leaves": 31, # Reduced leaves slightly since we only have 64 dimensions now
"learning_rate": 0.05,
"feature_fraction": 0.8,
"bagging_fraction": 0.8,
"bagging_freq": 5,
"verbose": -1,
"seed": SEED,
}
callbacks = [lgb.early_stopping(stopping_rounds=25, verbose=False)]
lgb_model = lgb.train(
params,
lgb_train,
num_boost_round=500,
valid_sets=[lgb_val],
callbacks=callbacks,
)
lgb_model.save_model(os.path.join(MODEL_SAVE_DIR, "lgbm_nativity.txt"))
prob_lgb = lgb_model.predict(X_val)
except Exception as e:
print(f"LightGBM skipped: {e}")
prob_lgb = None
# ---- 4. Load best and evaluate all + ensemble ----
model_mlp.load_state_dict(
torch.load(
os.path.join(MODEL_SAVE_DIR, "nativity_model.pth"), map_location=device
)
)
model_att.load_state_dict(
torch.load(
os.path.join(MODEL_SAVE_DIR, "nativity_model_attention.pth"),
map_location=device,
)
)
pred_mlp, prob_mlp = eval_model(
model_mlp, val_loader, device, threshold=EVAL_THRESHOLD
)
pred_att, prob_att = eval_model(
model_att, val_loader, device, threshold=EVAL_THRESHOLD
)
# Ensemble: average probs from MLP, attention, and LightGBM (if present)
prob_list = [prob_mlp, prob_att]
if prob_lgb is not None:
prob_list.append(prob_lgb)
prob_ens = np.stack(prob_list, axis=0).mean(axis=0)
# Threshold sweep on validation
best_threshold = EVAL_THRESHOLD
best_f1_ens = 0.0
print("\nThreshold sweep on validation (ensemble):")
for t in THRESHOLD_CANDIDATES:
pred_t = (prob_ens[:, 1] >= t).astype(int)
f1_t = f1_score(y_true, pred_t, average="macro", zero_division=0)
print(f" t={t:.2f} F1={f1_t:.4f}")
if f1_t > best_f1_ens:
best_f1_ens = f1_t
best_threshold = t
with open(BEST_THRESHOLD_PATH, "w") as f:
f.write(str(best_threshold))
print(
f"Best threshold (val F1): {best_threshold} -> F1={best_f1_ens:.4f} (saved to {BEST_THRESHOLD_PATH})"
)
pred_ens = (prob_ens[:, 1] >= best_threshold).astype(int)
def metrics_row(name, preds):
acc = accuracy_score(y_true, preds)
f1 = f1_score(y_true, preds, average="macro", zero_division=0)
prec = precision_score(y_true, preds, average="macro", zero_division=0)
rec = recall_score(y_true, preds, average="macro", zero_division=0)
cm = confusion_matrix(y_true, preds)
return {"name": name, "acc": acc, "f1": f1, "prec": prec, "rec": rec, "cm": cm}
pred_lgb = np.argmax(prob_lgb, axis=1) if prob_lgb is not None else None
n_ens = len(prob_list)
results = [
metrics_row("MLP (baseline)", pred_mlp),
metrics_row("MLP + attention (prosody)", pred_att),
metrics_row(f"Ensemble ({n_ens} models, t={best_threshold})", pred_ens),
]
if pred_lgb is not None:
results.append(metrics_row("LightGBM", pred_lgb))
print("\n" + "=" * 60)
print("COMPARISON (validation set)")
print("=" * 60)
print(
f"{'Model':<30} {'Accuracy':>10} {'F1 macro':>10} {'Precision':>10} {'Recall':>10}"
)
print("-" * 72)
for r in results:
print(
f"{r['name']:<30} {r['acc']:>10.4f} {r['f1']:>10.4f} {r['prec']:>10.4f} {r['rec']:>10.4f}"
)
print("\nConfusion matrices (rows=true, cols=pred), Native=0, Non-Native=1:")
for r in results:
print(f" {r['name']}:")
print(f" {r['cm']}")
best = max(results, key=lambda x: x["f1"])
print(f"\nBest by F1: {best['name']} (F1={best['f1']:.4f})")
print(
"\nSaved: models/nativity_model.pth, models/nativity_model_attention.pth, models/best_threshold.txt"
)
print(" models/lgbm_nativity.txt (if LightGBM)")
if __name__ == "__main__":
main()