File size: 7,970 Bytes
cdfb7d6
c4a7a0e
 
cdfb7d6
 
 
 
 
 
 
 
 
875a0a4
cdfb7d6
c4a7a0e
 
 
 
 
 
 
cdfb7d6
 
 
 
5e96656
cdfb7d6
 
 
 
 
 
 
 
5e96656
cdfb7d6
5e96656
6a10fdd
 
 
 
5e96656
 
c4a7a0e
cdfb7d6
5e96656
 
 
 
 
 
 
 
 
 
 
 
 
cdfb7d6
 
 
6a10fdd
 
875a0a4
 
 
cdfb7d6
 
6a10fdd
 
cdfb7d6
6a10fdd
cdfb7d6
 
 
 
 
 
 
 
5e96656
 
 
 
cdfb7d6
875a0a4
5e96656
cdfb7d6
c4a7a0e
 
 
 
 
5e96656
 
 
 
 
 
 
 
 
 
 
 
 
6a10fdd
 
5e96656
cdfb7d6
 
 
 
 
 
 
 
 
 
5e96656
 
 
 
 
 
 
cdfb7d6
5e96656
cdfb7d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e96656
cdfb7d6
 
 
5e96656
cdfb7d6
 
 
 
 
 
 
 
5e96656
cdfb7d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e96656
 
 
cdfb7d6
c4a7a0e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cdfb7d6
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
"""
Run inference on test CSV: download audio, chunk, embed with Wav2Vec (middle layers), classify.
Arabic Native vs Non-Native. Uses language-agnostic middle-layer embeddings.
"""
import os
import sys
import pandas as pd
import numpy as np
import torch
import librosa
import requests
import io
from transformers import Wav2Vec2Model, AutoFeatureExtractor
from tqdm import tqdm
from sklearn.metrics import (
    accuracy_score,
    f1_score,
    precision_score,
    recall_score,
    confusion_matrix,
)

# Import shared classifier (same architecture as training)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from model import NativityClassifier
from extract_embeddings import get_mfcc_from_audio, get_pitch_stats, PROSODY_EMBED_DIM, EMBED_DIM_WITH_MFCC

# -----------------------------------------------------------------------------
# Config
# -----------------------------------------------------------------------------
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model_path = "models/nativity_model.pth"
input_csv = "manifests/test_data.csv"
output_csv = "final_predictions.csv"
EMBED_DIM_FILE = "manifests/embedding_dim.txt"

# Model arch must match train_classifier_compare / model.py
HIDDEN_DIMS = (256, 128)  # Must match train_classifier_compare.py
DROPOUT = 0.4
PCA_COMPONENTS = 256
PCA_PATH = "models/pca_transform.pkl"
EVAL_THRESHOLD_DEFAULT = 0.35
BEST_THRESHOLD_PATH = "models/best_threshold.txt"
PROSODY_LAYERS = (4, 6, 8, 10)  # must match extract_embeddings.py (prosody-focused)

# Embedding dim: 2051 (prosody) or 2090 (prosody+MFCC)
embedding_dim = PROSODY_EMBED_DIM
if os.path.isfile(EMBED_DIM_FILE):
    with open(EMBED_DIM_FILE) as f:
        embedding_dim = int(f.read().strip())

# Use validation-picked threshold if available (from train_classifier_compare.py)
if os.path.isfile(BEST_THRESHOLD_PATH):
    with open(BEST_THRESHOLD_PATH) as f:
        EVAL_THRESHOLD = float(f.read().strip())
else:
    EVAL_THRESHOLD = EVAL_THRESHOLD_DEFAULT

# -----------------------------------------------------------------------------
# Load models
# -----------------------------------------------------------------------------
import joblib

model_name = "facebook/wav2vec2-large-xlsr-53"
feature_extractor = AutoFeatureExtractor.from_pretrained(model_name)
wav2vec = Wav2Vec2Model.from_pretrained(model_name).to(device)
wav2vec.eval()

pca = joblib.load(PCA_PATH)

classifier = NativityClassifier(
    input_dim=PCA_COMPONENTS,
    hidden_dims=HIDDEN_DIMS,
    output_dim=2,
    dropout=DROPOUT,
).to(device)
classifier.load_state_dict(torch.load(model_path, map_location=device))
classifier.eval()


def get_embedding(audio_np, embed_dim=None):
    """Prosody embedding: mean+std+pitch (2051-d) or +MFCC (2090-d); L2 norm to match training."""
    if embed_dim is None:
        embed_dim = embedding_dim
    with torch.no_grad():
        input_values = feature_extractor(
            audio_np, return_tensors="pt", sampling_rate=16000
        ).input_values.to(device)
        outputs = wav2vec(input_values, output_hidden_states=True)
        h = outputs.hidden_states
        num_layers = len(h) - 1
        layer_idx = [min(max(l, 1), num_layers) for l in PROSODY_LAYERS]
        embs = [h[i] for i in layer_idx]
        time_seq = torch.stack(embs, dim=0).mean(dim=0).squeeze(0)  # (T, 1024)
        emb_mean = time_seq.mean(dim=0).cpu().numpy().astype(np.float32)
        emb_std = time_seq.std(dim=0).cpu().numpy().astype(np.float32)
        np.nan_to_num(emb_std, copy=False, nan=0.0, posinf=0.0, neginf=0.0)
    pitch = get_pitch_stats(audio_np, sr=16000)
    out = np.concatenate([emb_mean, emb_std, pitch]).astype(np.float32)
    if embed_dim == EMBED_DIM_WITH_MFCC:
        mfcc_39 = get_mfcc_from_audio(audio_np, sr=16000)
        out = np.concatenate([out, mfcc_39]).astype(np.float32)
    n = np.linalg.norm(out)
    if n > 1e-8:
        out = out / n
    out = out.reshape(1, -1)
    # Apply PCA to match training pipeline (2051-d -> 256-d)
    out = pca.transform(out).astype(np.float32)
    return torch.tensor(out, dtype=torch.float32, device=device)


def process_file_and_predict(url):
    """
    Download, chunk (10s), embed each chunk, then aggregate with confidence-weighted voting.
    Returns (label_str, confidence).
    """
    try:
        response = requests.get(url, timeout=15)
        response.raise_for_status()
        raw = response.content
        if not raw or len(raw) < 100:
            return "Error", 0.0
        ct = (response.headers.get("Content-Type") or "").lower()
        if "text/html" in ct or raw.lstrip()[:50].startswith(b"<!") or raw.lstrip()[:20].startswith(b"<?"):
            return "Error", 0.0
        audio_bytes = io.BytesIO(raw)
        y, sr = librosa.load(audio_bytes, sr=16000, mono=True)
    except Exception:
        return "Error", 0.0

    chunk_size = 160000  # 10 sec at 16 kHz
    chunks = [
        y[i : i + chunk_size]
        for i in range(0, len(y), chunk_size)
        if len(y[i : i + chunk_size]) == chunk_size
    ]
    if not chunks:
        return "Error", 0.0

    chunk_preds = []   # 0 = Native, 1 = Non-Native
    chunk_confs = []   # probability of predicted class

    with torch.no_grad():
        for chunk in chunks:
            emb = get_embedding(chunk, embed_dim=embedding_dim)
            logits = classifier(emb)
            probs = torch.softmax(logits, dim=1)
            p1 = probs[0, 1].item()
            pred = 1 if p1 >= EVAL_THRESHOLD else 0
            conf = p1 if pred == 1 else (1.0 - p1)
            chunk_preds.append(pred)
            chunk_confs.append(conf)

    # Confidence-weighted voting: weight each chunk by its confidence
    weights = np.array(chunk_confs, dtype=np.float64)
    weights = weights / (weights.sum() + 1e-10)
    weighted_score = np.sum(np.array(chunk_preds) * weights)
    final_label = "Non-Native" if weighted_score >= EVAL_THRESHOLD else "Native"
    # Final confidence = weighted average of confidences (or max)
    final_conf = float(np.average(chunk_confs, weights=weights))

    return final_label, final_conf


def main():
    df = pd.read_csv(input_csv)
    results = []
    print(f"Predicting for {len(df)} files (device={device})...")
    for _, row in tqdm(df.iterrows(), total=len(df)):
        label, conf = process_file_and_predict(row["audio_url"])
        results.append({
            "dp_id": row["dp_id"],
            "prediction": label,
            "confidence_score": round(conf, 4),
        })
    out_df = pd.DataFrame(results)
    out_df.to_csv(output_csv, index=False)
    n_ok = (out_df["prediction"] != "Error").sum()
    n_fail = len(out_df) - n_ok
    print(f"Done. Saved to {output_csv} ({n_ok} predicted, {n_fail} failed to load)")

    # If ground-truth labels exist (nativity_status), print test metrics like validation
    if "nativity_status" in df.columns:
        mask = out_df["prediction"] != "Error"
        y_true_str = df.loc[mask, "nativity_status"].values
        y_pred_str = out_df.loc[mask, "prediction"].values
        map_fn = lambda s: 1 if s == "Non-Native" else 0
        y_true = np.array([map_fn(s) for s in y_true_str])
        y_pred = np.array([map_fn(s) for s in y_pred_str])

        acc = accuracy_score(y_true, y_pred)
        f1 = f1_score(y_true, y_pred, average="macro", zero_division=0)
        prec = precision_score(y_true, y_pred, average="macro", zero_division=0)
        rec = recall_score(y_true, y_pred, average="macro", zero_division=0)
        cm = confusion_matrix(y_true, y_pred)

        print("\nTest metrics (from test_data.csv, using nativity_status):")
        print(f"  Accuracy:  {acc:.4f}")
        print(f"  F1 macro:  {f1:.4f}")
        print(f"  Precision: {prec:.4f}")
        print(f"  Recall:    {rec:.4f}")
        print("  Confusion matrix (rows=true, cols=pred):")
        print(f"    {cm}")
        print("  (Native=0, Non-Native=1)")


if __name__ == "__main__":
    main()