Spaces:
Sleeping
Sleeping
Tawhid Bin Omar
Improve model robustness: weighted chunk scoring, lower thresholds, enhanced feature sensitivity
ed4a95a | """ | |
| Processor module adapted from static.ipynb for VocalGuard. | |
| Provides process_file(path) which returns a dict with final_risk, label, and details. | |
| Includes ML classifier layer for improved accuracy through training. | |
| """ | |
| import os | |
| import sys | |
| import numpy as np | |
| import librosa | |
| import joblib | |
| import logging | |
| from pathlib import Path | |
| # Configure logging | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format='%(asctime)s - %(levelname)s - %(message)s', | |
| handlers=[logging.StreamHandler(sys.stdout)] | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # Optional imports - fallback to heuristics if not available | |
| try: | |
| import torch | |
| from transformers import WhisperProcessor, WhisperForConditionalGeneration | |
| from sentence_transformers import SentenceTransformer | |
| TORCH_AVAILABLE = True | |
| except Exception: | |
| TORCH_AVAILABLE = False | |
| try: | |
| from sklearn.ensemble import RandomForestClassifier | |
| from sklearn.preprocessing import StandardScaler | |
| SKLEARN_AVAILABLE = True | |
| except Exception: | |
| SKLEARN_AVAILABLE = False | |
| # Global model cache (lazy loading) | |
| _whisper_model = None | |
| _whisper_processor = None | |
| _sentence_model = None | |
| _ml_classifier = None | |
| _feature_scaler = None | |
| def get_whisper_model(): | |
| """Lazy load Whisper model""" | |
| global _whisper_model, _whisper_processor | |
| if _whisper_model is None and TORCH_AVAILABLE: | |
| logger.info("Loading Whisper model (first time only)...") | |
| _whisper_processor = WhisperProcessor.from_pretrained( | |
| "openai/whisper-small", | |
| cache_dir=MODEL_DIR | |
| ) | |
| _whisper_model = WhisperForConditionalGeneration.from_pretrained( | |
| "openai/whisper-small", | |
| cache_dir=MODEL_DIR, | |
| torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32, | |
| low_cpu_mem_usage=True | |
| ) | |
| logger.info("Whisper model loaded") | |
| return _whisper_processor, _whisper_model | |
| def get_sentence_model(): | |
| """Lazy load sentence transformer""" | |
| global _sentence_model | |
| if _sentence_model is None and TORCH_AVAILABLE: | |
| logger.info("Loading sentence transformer...") | |
| _sentence_model = SentenceTransformer( | |
| 'all-MiniLM-L6-v2', | |
| cache_folder=MODEL_DIR | |
| ) | |
| logger.info("Sentence model loaded") | |
| return _sentence_model | |
| def get_ml_classifier(): | |
| """Lazy load trained ML classifier if available""" | |
| global _ml_classifier, _feature_scaler | |
| if _ml_classifier is None and SKLEARN_AVAILABLE: | |
| classifier_path = os.path.join(MODEL_DIR, "scam_classifier.pkl") | |
| scaler_path = os.path.join(MODEL_DIR, "feature_scaler.pkl") | |
| if os.path.exists(classifier_path) and os.path.exists(scaler_path): | |
| try: | |
| _ml_classifier = joblib.load(classifier_path) | |
| _feature_scaler = joblib.load(scaler_path) | |
| print("✅ Trained ML classifier loaded - using enhanced accuracy mode") | |
| return _ml_classifier, _feature_scaler | |
| except Exception as e: | |
| print(f"Warning: Could not load ML classifier: {e}") | |
| return None, None | |
| else: | |
| print("ℹ️ No trained classifier found - using rule-based scoring") | |
| print(" To train a classifier, run: python train_model.py") | |
| return _ml_classifier, _feature_scaler | |
| # Config | |
| CHUNK_SECONDS = 10 | |
| TARGET_SR = 16000 | |
| MODEL_DIR = os.path.join(os.path.dirname(__file__), "models") | |
| os.makedirs(MODEL_DIR, exist_ok=True) | |
| # Comprehensive scam vocabulary - organized by threat category | |
| SCAM_TERMS = { | |
| # Critical urgency words (highest weight) | |
| "urgency": ["urgent", "immediately", "now", "today", "right now", "asap", "expire", "expires", | |
| "expired", "deadline", "final", "last chance", "limited time", "act now", "hurry", | |
| "within hours", "time sensitive", "critical", "emergency", "instant"], | |
| # Authority impersonation (very high weight) | |
| "authority": ["irs", "police", "fbi", "government", "officer", "agent", "department", "federal", | |
| "sheriff", "marshal", "investigator", "prosecutor", "attorney", "social security", | |
| "medicare", "medicaid", "customs", "immigration", "enforcement", "treasury", | |
| "homeland security", "border patrol", "detective", "inspector", "official", | |
| "badge number", "case number", "file number", "claim number"], | |
| # Financial institutions | |
| "financial": ["bank", "credit card", "visa", "mastercard", "paypal", "venmo", "zelle", | |
| "account", "routing number", "card number", "cvv", "pin", "password", | |
| "wells fargo", "chase", "bank of america", "citibank", "capital one", | |
| "transaction", "pending", "overdrawn", "overdraft", "declined"], | |
| # Threat words (high weight) | |
| "threats": ["arrest", "arrested", "warrant", "suspended", "blocked", "frozen", "seized", | |
| "lawsuit", "legal action", "court", "jail", "prison", "penalty", "fine", | |
| "consequences", "investigation", "fraud", "charges", "prosecution", "terminate", | |
| "revoked", "cancelled", "legal trouble", "criminal", "indictment", "subpoena"], | |
| # Information requests (medium-high weight) | |
| "requests": ["verify", "confirm", "provide", "give", "send", "transfer", "wire", "payment", | |
| "social security number", "ssn", "date of birth", "mother maiden", "otp", | |
| "verification code", "access code", "passcode", "authenticate", "validate", | |
| "full name", "address", "zip code", "last four", "account number", | |
| "verification", "proceed", "process", "documentation", "legitimacy", | |
| "protocol", "compliance", "specifics", "appropriate channels"], | |
| # Payment methods often used in scams | |
| "payment": ["gift card", "google play", "itunes", "amazon card", "prepaid card", "reload", | |
| "bitcoin", "cryptocurrency", "crypto", "western union", "money gram", "cash app", | |
| "target card", "walmart card", "steam card", "vanilla visa", "greendot", | |
| "cash pickup", "money order", "cashiers check"], | |
| # Deception/reward words (medium weight) | |
| "deception": ["congratulations", "winner", "won", "prize", "lottery", "sweepstakes", "refund", | |
| "rebate", "claim", "eligible", "selected", "qualified", "free", "guarantee", | |
| "reward", "bonus", "approved", "pre-approved", "exclusive", "special offer", | |
| "limited offer", "one time", "cant miss", "incredible deal", "claims", | |
| "vague", "refuses", "seeks", "maintains", "investment opportunity"], | |
| # Technical support scams | |
| "tech": ["virus", "malware", "hacked", "breach", "compromised", "microsoft", "apple", | |
| "tech support", "computer", "windows", "error", "warning", "firewall", | |
| "security alert", "suspicious activity", "ip address", "remote access", | |
| "teamviewer", "anydesk", "license expired", "subscription", "access", | |
| "security breach", "system alert"], | |
| # Pressure tactics | |
| "pressure": ["must", "need to", "have to", "required", "mandatory", "cannot", "will not", | |
| "unless", "or else", "final notice", "last warning", "do not ignore", | |
| "do not hang up", "stay on line", "dont delay", "no choice", "only option", | |
| "pressures", "before", "directly", "without", "taking action", "attempts to"], | |
| # Isolation tactics (new category) | |
| "isolation": ["do not tell", "keep confidential", "dont share", "between us", "secret", | |
| "do not contact", "handle personally", "direct line", "callback number", | |
| "do not call back", "use this number"], | |
| # Call spoofing indicators (new category) | |
| "spoofing": ["callback", "reference number", "confirmation code", "direct extension", | |
| "secure line", "private line", "department line", "this number only"] | |
| } | |
| # Contextual scam phrases (multi-word patterns) | |
| SCAM_PHRASES = [ | |
| "your account has been", | |
| "suspicious activity on your", | |
| "we need to verify", | |
| "call us back at", | |
| "press 1 to", | |
| "final attempt to reach", | |
| "avoid legal action", | |
| "going to be arrested", | |
| "warrant for your arrest", | |
| "social security number has been", | |
| "do not hang up", | |
| "stay on the line", | |
| "gift cards", | |
| "before end of business", | |
| "within 24 hours", | |
| "call back immediately", | |
| "refund is pending", | |
| "account will be closed", | |
| "freeze your account", | |
| # New high-confidence phrases from transcript analysis | |
| "attempts to", | |
| "verification of your", | |
| "security verification", | |
| "proceed with", | |
| "taking action", | |
| "through official channels", | |
| "without verification", | |
| "claims to be", | |
| "refuses to provide", | |
| "maintains urgency", | |
| "directly to avoid" | |
| ] | |
| def scam_features(text): | |
| """Extract advanced rule-based scam features with contextual analysis""" | |
| text_lower = text.lower() | |
| words = text_lower.split() | |
| sentences = [s.strip() for s in text.split('.') if s.strip()] | |
| if len(words) == 0: | |
| logger.debug("Empty text, returning zero features") | |
| return [0.0] * 15 | |
| logger.debug(f"Analyzing {len(words)} words, {len(sentences)} sentences") | |
| # Category-weighted scoring | |
| urgency_score = sum(1 for w in words if any(term in text_lower for term in SCAM_TERMS["urgency"])) / len(words) | |
| authority_score = sum(1 for w in words if any(term in text_lower for term in SCAM_TERMS["authority"])) / len(words) | |
| threat_score = sum(1 for w in words if any(term in text_lower for term in SCAM_TERMS["threats"])) / len(words) | |
| request_score = sum(1 for w in words if any(term in text_lower for term in SCAM_TERMS["requests"])) / len(words) | |
| payment_score = sum(1 for w in words if any(term in text_lower for term in SCAM_TERMS["payment"])) / len(words) | |
| isolation_score = sum(1 for w in words if any(term in text_lower for term in SCAM_TERMS["isolation"])) / len(words) | |
| # Contextual phrase detection (more accurate than individual words) | |
| phrase_matches = sum(1 for phrase in SCAM_PHRASES if phrase in text_lower) | |
| phrase_score = min(1.0, phrase_matches / 3.0) # Normalize to 0-1 | |
| # Advanced linguistic patterns | |
| question_ratio = text.count('?') / (len(sentences) + 1) # Scammers ask many questions | |
| imperative_ratio = sum(1 for s in sentences if s.strip().startswith(("verify", "confirm", "call", "provide", "send", "press", "do not"))) / (len(sentences) + 1) | |
| number_count = sum(1 for w in words if any(c.isdigit() for c in w)) / len(words) # Phone numbers, account numbers | |
| # Readability (scammers use simple language to manipulate) | |
| avg_word_length = sum(len(w) for w in words) / len(words) if words else 0 | |
| readability_score = 1.0 if avg_word_length < 4.5 else 0.0 # Suspiciously simple | |
| # Repetition detection (scammers repeat key points) | |
| unique_words = len(set(words)) | |
| repetition_score = 1.0 - (unique_words / len(words)) if words else 0.0 | |
| # Combination flags (highly suspicious patterns) | |
| urgency_plus_authority = 1.0 if urgency_score > 0.015 and authority_score > 0.015 else 0.0 | |
| threat_plus_request = 1.0 if threat_score > 0.015 and request_score > 0.015 else 0.0 | |
| isolation_plus_payment = 1.0 if isolation_score > 0.01 and payment_score > 0.01 else 0.0 | |
| return [ | |
| urgency_score * 10, # 0: Urgency indicators | |
| authority_score * 10, # 1: Authority impersonation | |
| threat_score * 10, # 2: Threats | |
| request_score * 10, # 3: Information requests | |
| payment_score * 10, # 4: Payment methods | |
| phrase_score * 3, # 5: Contextual phrases (NEW) | |
| question_ratio * 2, # 6: Question density | |
| imperative_ratio * 2, # 7: Command density | |
| number_count * 5, # 8: Number frequency | |
| isolation_score * 8, # 9: Isolation tactics (NEW) | |
| readability_score * 2, # 10: Suspiciously simple (NEW) | |
| repetition_score * 3, # 11: Repetition (NEW) | |
| urgency_plus_authority, # 12: Combo flag 1 | |
| threat_plus_request, # 13: Combo flag 2 | |
| isolation_plus_payment # 14: Combo flag 3 (NEW) | |
| ] | |
| def extract_audio_features_from_array(y, sr=TARGET_SR): | |
| """Extract audio features for EMS/VAS scoring with temporal dynamics""" | |
| y = y.astype(np.float32) | |
| if len(y) < sr: # Less than 1 second | |
| return np.zeros(14, dtype=np.float32) | |
| # Pitch features (emotional stress indicators) | |
| try: | |
| pitch = librosa.yin(y, fmin=80, fmax=300, sr=sr) | |
| pitch_var = float(np.nanvar(pitch)) | |
| pitch_mean = float(np.nanmean(pitch)) | |
| # Pitch range (larger range = emotional manipulation) | |
| pitch_range = float(np.nanmax(pitch) - np.nanmin(pitch)) if len(pitch) > 0 else 0.0 | |
| except Exception: | |
| pitch_var = 0.0 | |
| pitch_mean = 150.0 | |
| pitch_range = 0.0 | |
| # Energy features (pressure/aggression) | |
| energy = librosa.feature.rms(y=y) | |
| energy_mean = float(np.mean(energy)) if energy.size else 0.0 | |
| energy_var = float(np.var(energy)) if energy.size else 0.0 | |
| # Energy dynamics (sudden changes = stress/pressure) | |
| energy_delta = float(np.mean(np.abs(np.diff(energy))) if energy.size > 1 else 0.0) | |
| # Speaking rate (scammers often speak fast under pressure) | |
| try: | |
| onset_env = librosa.onset.onset_strength(y=y, sr=sr) | |
| tempo = librosa.beat.tempo(onset_envelope=onset_env, sr=sr)[0] | |
| speaking_rate = float(tempo / 120.0) # Normalize to typical speech | |
| except Exception: | |
| speaking_rate = 1.0 | |
| # MFCC features (voice quality and biometrics) | |
| mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13) | |
| mfcc_var = float(np.var(mfcc)) if mfcc.size else 0.0 | |
| # MFCC delta (voice quality changes over time) | |
| mfcc_delta = float(np.mean(np.abs(librosa.feature.delta(mfcc)))) if mfcc.size > 0 else 0.0 | |
| # Spectral features (voice naturalness) | |
| spec_flatness = float(np.mean(librosa.feature.spectral_flatness(y=y))) if y.size else 0.0 | |
| spec_centroid = float(np.mean(librosa.feature.spectral_centroid(y=y, sr=sr))) if y.size else 0.0 | |
| zcr = float(np.mean(librosa.feature.zero_crossing_rate(y))) if y.size else 0.0 | |
| # Harmonic analysis (synthetic voice detection) | |
| harmonic = librosa.effects.harmonic(y) | |
| noise = y - harmonic | |
| harmonic_ratio = float(np.sum(harmonic**2) / (np.sum(noise**2) + 1e-6)) | |
| # Jitter and shimmer approximations (voice stability) | |
| # Higher jitter/shimmer = unnatural voice or stress | |
| frame_length = min(2048, len(y)) | |
| frames = librosa.util.frame(y, frame_length=frame_length, hop_length=frame_length//2) | |
| frame_energies = np.sqrt(np.mean(frames**2, axis=0)) | |
| shimmer = float(np.std(frame_energies) / (np.mean(frame_energies) + 1e-6) if len(frame_energies) > 1 else 0.0) | |
| return np.array([pitch_var, pitch_mean, pitch_range, energy_mean, energy_var, energy_delta, | |
| speaking_rate, mfcc_var, mfcc_delta, spec_flatness, spec_centroid, | |
| zcr, harmonic_ratio, shimmer], dtype=np.float32) | |
| def transcribe_chunk(y, sr=TARGET_SR): | |
| """Transcribe audio chunk to text""" | |
| y = y.astype(np.float32) | |
| whisper_processor, whisper_model = get_whisper_model() | |
| if whisper_processor is None or whisper_model is None: | |
| return "" | |
| import torch | |
| inputs = whisper_processor(y, sampling_rate=sr, return_tensors="pt") | |
| with torch.no_grad(): | |
| ids = whisper_model.generate(inputs.input_features, max_new_tokens=128) | |
| text = whisper_processor.batch_decode(ids, skip_special_tokens=True)[0].lower() | |
| return text | |
| def compute_lts_score_from_text(text): | |
| """Compute Linguistic Threat Score with contextual feature weighting""" | |
| if not text or len(text.strip()) < 3: | |
| return 0.0 | |
| sentence_model = get_sentence_model() | |
| features = np.array(scam_features(text), dtype=np.float32) | |
| # Enhanced weighted feature combination (optimized for 15 features) | |
| # Features: [urgency, authority, threat, request, payment, phrases, questions, imperatives, | |
| # numbers, isolation, readability, repetition, combo1, combo2, combo3] | |
| # Boosted weights for high-confidence indicators: authority, threats, payment, phrases | |
| weights = np.array([0.14, 0.18, 0.16, 0.11, 0.10, 0.14, 0.03, 0.03, | |
| 0.02, 0.04, 0.01, 0.01, 0.02, 0.01, 0.00], dtype=np.float32) | |
| rule_score = float(np.clip(np.sum(features * weights), 0.0, 1.0)) | |
| if sentence_model is None: | |
| return rule_score | |
| # Semantic embedding analysis with better normalization | |
| try: | |
| emb = sentence_model.encode([text], convert_to_numpy=True, show_progress_bar=False) | |
| emb_norm = np.linalg.norm(emb) | |
| emb_score = min(1.0, emb_norm / 12.0) | |
| except Exception: | |
| emb_score = 0.0 | |
| # Higher weight on rule-based (more reliable and interpretable) | |
| final_score = 0.80 * rule_score + 0.20 * emb_score | |
| return float(np.clip(final_score, 0.0, 1.0)) | |
| def compute_ems_score_from_audio_features(ems_features_row): | |
| """Compute Emotional Manipulation Score with temporal dynamics""" | |
| # Features: [pitch_var, pitch_mean, pitch_range, energy_mean, energy_var, energy_delta, speaking_rate] | |
| vals = np.array(ems_features_row, dtype=np.float32) | |
| # More sensitive normalization for better detection | |
| pitch_var_norm = np.clip(vals[0] / 3000.0, 0, 1) # Lowered threshold for better sensitivity | |
| pitch_high = 1.0 if vals[1] > 160 else (0.5 if vals[1] > 140 else 0.0) # Graduated scale | |
| pitch_range_norm = np.clip(vals[2] / 120.0, 0, 1) # Lower threshold | |
| energy_norm = np.clip(vals[3] / 0.2, 0, 1) # More sensitive to volume | |
| energy_var_norm = np.clip(vals[4] / 0.03, 0, 1) # More sensitive to variance | |
| energy_change = np.clip(vals[5] / 0.08, 0, 1) # More sensitive to sudden changes | |
| fast_speech = 1.0 if vals[6] > 1.2 else (0.5 if vals[6] > 1.05 else 0.0) # Graduated scale | |
| # Enhanced weighting emphasizing stress indicators | |
| score = (0.22 * pitch_var_norm + # Increased - key stress indicator | |
| 0.13 * pitch_high + | |
| 0.17 * pitch_range_norm + # Increased - emotional manipulation | |
| 0.18 * energy_norm + | |
| 0.13 * energy_var_norm + | |
| 0.12 * energy_change + # Increased - pressure tactics | |
| 0.05 * fast_speech) | |
| return float(np.clip(score, 0.0, 1.0)) | |
| def compute_vas_score(X_vas_raw_row): | |
| """Compute Voice Authenticity Score with biometric features""" | |
| # Features: [mfcc_var, mfcc_delta, spec_flatness, spec_centroid, zcr, harmonic_ratio, shimmer] | |
| # Higher scores indicate unnatural/synthetic voice | |
| return float(np.clip( | |
| 0.22 * X_vas_raw_row[0] + # MFCC variance | |
| 0.18 * X_vas_raw_row[1] + # MFCC delta (voice stability) | |
| 0.20 * X_vas_raw_row[2] + # Spectral flatness (naturalness) | |
| 0.10 * (1 - min(1, X_vas_raw_row[3] / 3000.0)) + # Spectral centroid (inverted) | |
| 0.12 * X_vas_raw_row[4] + # Zero crossing rate | |
| 0.10 * (1 - min(1, X_vas_raw_row[5] / 10.0)) + # Harmonic ratio (inverted) | |
| 0.08 * min(1, X_vas_raw_row[6] * 10), # Shimmer (voice stability) | |
| 0.0, 1.0 | |
| )) | |
| def chunk_audio_file(path, chunk_seconds=CHUNK_SECONDS, sr=TARGET_SR): | |
| """Split audio file into chunks""" | |
| try: | |
| # Try loading directly | |
| y, sr_loaded = librosa.load(path, sr=sr, mono=True) | |
| except Exception as e: | |
| # If it fails (e.g., WebM format), try using soundfile or audioread | |
| import soundfile as sf | |
| try: | |
| # Try with soundfile first | |
| y, sr_loaded = sf.read(path) | |
| if len(y.shape) > 1: | |
| y = y.mean(axis=1) # Convert to mono | |
| # Resample if needed | |
| if sr_loaded != sr: | |
| import scipy.signal | |
| y = scipy.signal.resample(y, int(len(y) * sr / sr_loaded)) | |
| except Exception: | |
| # Last resort: use audioread | |
| import audioread | |
| with audioread.audio_open(path) as f: | |
| sr_loaded = f.samplerate | |
| y = [] | |
| for buf in f: | |
| y.append(np.frombuffer(buf, dtype=np.int16).astype(np.float32) / 32768.0) | |
| y = np.concatenate(y) | |
| if len(y.shape) > 1: | |
| y = y.mean(axis=1) | |
| total_len = y.shape[0] | |
| chunk_len = int(chunk_seconds * sr) | |
| chunks = [] | |
| for start in range(0, total_len, chunk_len): | |
| end = min(start + chunk_len, total_len) | |
| chunks.append(y[start:end]) | |
| return chunks | |
| def process_and_score_chunk(y_chunk, chunk_index): | |
| """Process a single audio chunk and return scores""" | |
| logger.info(f"[Chunk {chunk_index}] Processing {len(y_chunk)} samples ({len(y_chunk)/TARGET_SR:.2f}s)") | |
| # Transcription | |
| transcription = transcribe_chunk(y_chunk, TARGET_SR) | |
| logger.info(f"[Chunk {chunk_index}] Transcription ({len(transcription)} chars): '{transcription}'") | |
| # LTS | |
| lts = compute_lts_score_from_text(transcription) | |
| logger.info(f"[Chunk {chunk_index}] LTS: {lts:.3f}") | |
| # Audio features (14 features now) | |
| features = extract_audio_features_from_array(y_chunk, TARGET_SR) | |
| logger.info(f"[Chunk {chunk_index}] Audio features: pitch_var={features[0]:.2f}, pitch_mean={features[1]:.2f}, energy_mean={features[3]:.4f}") | |
| # EMS features: pitch_var, pitch_mean, pitch_range, energy_mean, energy_var, energy_delta, speaking_rate | |
| ems_row = features[[0, 1, 2, 3, 4, 5, 6]] | |
| ems = compute_ems_score_from_audio_features(ems_row) | |
| logger.info(f"[Chunk {chunk_index}] EMS: {ems:.3f} (from features: {ems_row})") | |
| # VAS features: mfcc_var, mfcc_delta, spec_flatness, spec_centroid, zcr, harmonic_ratio, shimmer | |
| vas_raw = features[[7, 8, 9, 10, 11, 12, 13]] | |
| # Normalize VAS features (already in reasonable ranges) | |
| vas = compute_vas_score(vas_raw) | |
| logger.info(f"[Chunk {chunk_index}] VAS: {vas:.3f}") | |
| # Base risk calculation (rule-based) | |
| base_risk = 0.5 * lts + 0.3 * ems + 0.2 * vas | |
| logger.info(f"[Chunk {chunk_index}] Base risk: {base_risk:.3f} (LTS={lts:.3f}, EMS={ems:.3f}, VAS={vas:.3f})") | |
| # Try ML classifier for enhanced accuracy | |
| ml_risk = None | |
| ml_classifier, feature_scaler = get_ml_classifier() | |
| if ml_classifier is not None and feature_scaler is not None: | |
| try: | |
| # Combine all features for ML prediction | |
| ml_features = np.concatenate([ | |
| np.array(scam_features(transcription)), # 15 linguistic features | |
| features # 14 audio features | |
| ]).reshape(1, -1) # Shape: (1, 29) | |
| # Scale features | |
| ml_features_scaled = feature_scaler.transform(ml_features) | |
| # Get probability prediction | |
| ml_proba = ml_classifier.predict_proba(ml_features_scaled)[0] | |
| ml_risk = float(ml_proba[1]) # Probability of scam class | |
| # Ensemble: weighted average of rule-based and ML | |
| final_risk = 0.4 * base_risk + 0.6 * ml_risk | |
| logger.info(f"[Chunk {chunk_index}] ML risk: {ml_risk:.3f}, Final (ensemble): {final_risk:.3f}") | |
| except Exception as e: | |
| logger.warning(f"[Chunk {chunk_index}] ML prediction failed: {e}, using base risk") | |
| final_risk = base_risk | |
| else: | |
| final_risk = base_risk | |
| return { | |
| "chunk_index": int(chunk_index), | |
| "transcription": transcription, | |
| "LTS": float(lts), | |
| "EMS": float(ems), | |
| "VAS": float(vas), | |
| "FINAL_RISK": float(final_risk), | |
| "ml_enhanced": ml_risk is not None | |
| } | |
| def process_file(path): | |
| """Main entry point: process audio file and return risk assessment""" | |
| chunks = chunk_audio_file(path) | |
| results = [] | |
| for idx, c in enumerate(chunks): | |
| try: | |
| r = process_and_score_chunk(c, idx) | |
| except Exception as e: | |
| r = {"chunk_index": idx, "error": str(e), "FINAL_RISK": 0.0, "LTS": 0.0, "EMS": 0.0, "VAS": 0.0} | |
| results.append(r) | |
| # Weighted aggregation: prioritize longer chunks and high-risk detections | |
| final_risks = [r.get("FINAL_RISK", 0.0) for r in results] | |
| lts_scores = [r.get("LTS", 0.0) for r in results if "LTS" in r] | |
| ems_scores = [r.get("EMS", 0.0) for r in results if "EMS" in r] | |
| vas_scores = [r.get("VAS", 0.0) for r in results if "VAS" in r] | |
| # Weight chunks by their audio length (longer = more reliable) | |
| chunk_weights = [] | |
| for r in results: | |
| # Longer chunks get higher weight (normalized to 1.0 for 10s chunks) | |
| transcription_len = len(r.get("transcription", "")) | |
| # Weight by transcription length (more content = more reliable) | |
| weight = min(1.0, transcription_len / 100.0) if transcription_len > 0 else 0.1 | |
| chunk_weights.append(weight) | |
| # Normalize weights | |
| total_weight = sum(chunk_weights) if sum(chunk_weights) > 0 else 1.0 | |
| chunk_weights = [w / total_weight for w in chunk_weights] | |
| # Weighted average with emphasis on high scores (use max of weighted avg and top 2 chunks avg) | |
| if final_risks: | |
| weighted_risk = sum(r * w for r, w in zip(final_risks, chunk_weights)) | |
| # Also consider top 2 highest risks (catches strong scam signals) | |
| top_risks = sorted(final_risks, reverse=True)[:2] | |
| top_avg = np.mean(top_risks) if len(top_risks) > 0 else 0.0 | |
| # Take the higher of weighted average or top chunks average | |
| overall = float(max(weighted_risk, top_avg * 0.85)) # Slight discount for top avg | |
| else: | |
| overall = 0.0 | |
| # Weighted averages for breakdown scores | |
| if lts_scores and chunk_weights: | |
| avg_lts = float(sum(l * w for l, w in zip(lts_scores, chunk_weights[:len(lts_scores)]))) | |
| else: | |
| avg_lts = 0.0 | |
| if ems_scores and chunk_weights: | |
| avg_ems = float(sum(e * w for e, w in zip(ems_scores, chunk_weights[:len(ems_scores)]))) | |
| else: | |
| avg_ems = 0.0 | |
| if vas_scores and chunk_weights: | |
| avg_vas = float(sum(v * w for v, w in zip(vas_scores, chunk_weights[:len(vas_scores)]))) | |
| else: | |
| avg_vas = 0.0 | |
| # Determine label based on improved thresholds (more sensitive) | |
| if overall > 0.60: # Lowered from 0.70 | |
| label = "risky" | |
| status = "HIGH RISK" | |
| elif overall > 0.35: # Lowered from 0.40 | |
| label = "suspicious" | |
| status = "SUSPICIOUS" | |
| else: | |
| label = "safe" | |
| status = "SAFE" | |
| return { | |
| "final_risk": overall, | |
| "label": label, | |
| "status": status, | |
| "linguistic_threat_score": avg_lts, | |
| "emotional_manipulation_score": avg_ems, | |
| "voice_authenticity_score": avg_vas, | |
| "chunks": results, | |
| "num_chunks": len(results) | |
| } | |