yum-slop/TaSTT
Free self-hosted STT for VRChat.
git clone https://git.yummers.dev/yum-slop/TaSTT
bce0853
master
1#!/usr/bin/env python3 2import json 3import os 4import re 5from pathlib import Path 6import numpy as np 7import pandas as pd 8import pronouncing 9from sklearn .ensemble import GradientBoostingClassifier 10from sklearn .neighbors import KNeighborsClassifier 11from sklearn .preprocessing import StandardScaler 12from sklearn .pipeline import Pipeline 13from sklearn .model_selection import train_test_split 14from sklearn .metrics import classification_report ,confusion_matrix 15import joblib 16import warnings 17from sklearn .model_selection import StratifiedKFold ,cross_val_predict 18 19def count_syllables (word ): 20"""Count syllables in a word using pronouncing library with regex fallback.""" 21phones = pronouncing .phones_for_word (word .lower ()) 22if len (phones )== 0 : 23return 0 24return pronouncing .syllable_count (phones [0 ]) 25 26def text_syllable_count (text ): 27"""Count total syllables in text.""" 28words = re .findall (r'\b\w+\b' ,text ) 29return sum (count_syllables (word )for word in words ) 30 31def load_segments (log_dir ): 32"""Load segments from JSON files.""" 33segments = [] 34seen = set ()# To deduplicate identical segment metadata 35num_dupes = 0 36 37for root ,dirs ,files in os .walk (log_dir ): 38for file in files : 39if not file .endswith ('.json' ): 40continue 41with open (os .path .join (root ,file ),'r' )as f : 42data = json .load (f ) 43 44for segment in data ["segments" ]: 45if 'duration_sanity' not in segment : 46continue 47 48# Extract all available features 49text = segment ["text" ] 50duration = segment ["duration_sanity" ] 51 52# Calculate raw duration from timestamps 53start_ts = segment ["start_ts" ] 54end_ts = segment ["end_ts" ] 55raw_duration = end_ts - start_ts 56 57seg_data = { 58'avg_logprob' :segment ["avg_logprob" ], 59'no_speech_prob' :segment ["no_speech_prob" ], 60'duration_sanity' :duration , 61'raw_duration' :raw_duration , 62'compression_ratio' :segment ["compression_ratio" ], 63'text' :text 64 } 65 66# Add speech rate features 67n_syllables = text_syllable_count (text ) 68seg_data ['sps' ]= n_syllables / duration 69seg_data ['log_sps' ]= np .log1p (seg_data ['sps' ]) 70seg_data ['raw_sps' ]= n_syllables / raw_duration 71seg_data ['log_raw_sps' ]= np .log1p (seg_data ['raw_sps' ]) 72 73# Add derived features 74seg_data ['log_duration' ]= np .log1p (duration ) 75seg_data ['logprob_duration_interaction' ]= seg_data ['avg_logprob' ]* duration 76seg_data ['log_raw_duration' ]= np .log1p (raw_duration ) 77seg_data ['duration_ratio' ]= raw_duration / duration if duration > 0 else 1.0 78 79# Deduplicate: skip if this exact metadata already seen 80key = tuple (sorted (seg_data .items ())) 81if key in seen : 82num_dupes += 1 83continue 84seen .add (key ) 85 86segments .append (seg_data ) 87 88f"Skipped { num_dupes } duplicate segments" ) 89return pd .DataFrame (segments ) 90 91def log_seed_data (seeds_df ,seed_type ,label_desc ): 92"""Log comprehensive data for seed segments.""" 93if len (seeds_df )== 0 : 94return 95 96f"\n { seed_type } seeds ( { label_desc } ) - { len ( seeds_df ) } total:" ) 97for i , (_ ,seg )in enumerate (seeds_df .head (10 ).iterrows (),1 ): 98f" { i :3d } . SPS= { seg [ 'sps' ]:.2f } , Raw_SPS= { seg [ 'raw_sps' ]:.2f } , " 99f"logprob= { seg [ 'avg_logprob' ]:.3f } , no_speech= { seg [ 'no_speech_prob' ]:.3f } , " 100f"compression= { seg [ 'compression_ratio' ]:.2f } , duration= { seg [ 'duration_sanity' ]:.2f } s, " 101f"raw_duration= { seg [ 'raw_duration' ]:.2f } s" ) 102f" Text: ' { seg [ 'text' ] } '" ) 103# Show statistics 106f"\n { seed_type } seed statistics:" ) 107for metric ,col in [('SPS' ,'sps' ), ('Logprob' ,'avg_logprob' ), ('Compression' ,'compression_ratio' )]: 108data = seeds_df [col ] 109f" { metric } : mean= { data . mean ():.3f } , std= { data . std ():.3f } , min= { data . min ():.3f } , max= { data . max ():.3f } " ) 110 111def main (): 112# Find logs directory 113log_dir = None 114for pattern in ["ui/dist/win-unpacked/resources/logs" ]: 115paths = list (Path ("." ).glob (pattern )) 116if paths : 117log_dir = str (paths [0 ]) 118break 119 120if not log_dir : 121"Could not find logs directory." ) 122return 123 124# Load data 125"Loading segments from logs..." ) 126df = load_segments (log_dir ) 127 128if len (df )== 0 : 129"No segments found in logs!" ) 130return 131 132f"Loaded { len ( df ) } segments" ) 133 134# Print speech rate statistics 135"\nSpeech rate statistics:" ) 136f"Syllables per second: mean= { df [ 'sps' ]. mean ():.2f } , std= { df [ 'sps' ]. std ():.2f } , max= { df [ 'sps' ]. max ():.2f } " ) 137f"Raw syllables per second: mean= { df [ 'raw_sps' ]. mean ():.2f } , std= { df [ 'raw_sps' ]. std ():.2f } , max= { df [ 'raw_sps' ]. max ():.2f } " ) 138f"Duration ratio (raw/sanity): mean= { df [ 'duration_ratio' ]. mean ():.2f } , std= { df [ 'duration_ratio' ]. std ():.2f } " ) 139 140# Step 1: Apply heuristic rules for seed labeling 141"\nApplying heuristic rules for seed labeling..." ) 142 143# Conservative positive seeds (likely hallucinations) 144h_pos = ( 145 ((df ['avg_logprob' ]< - 0.85 )# This low of a logprob is almost always a hallucination 146| (df ['compression_ratio' ]> 2.3 )# High compressibility is usually a hallucination 147| (df ['sps' ]> 9 ))# No one speaks this fast 148& df ['text' ].str .contains ("Thank you" ,na = False )# Hack. Nothing good enough to 149 ) 150 151# Conservative negative seeds (likely valid) 152h_neg = ( 153 (df ['avg_logprob' ]> - 0.5 )# solid confidence drop 154& (df ['compression_ratio' ]< 1.2 ) 155& (df ['sps' ]< 9 ) 156 ) 157 158# Create seed labels (NaN for unlabeled) 159df ['seed_label' ]= np .where (h_pos ,1 , 160np .where (h_neg ,0 ,np .nan )) 161 162n_pos_seeds = (df ['seed_label' ]== 1 ).sum () 163n_neg_seeds = (df ['seed_label' ]== 0 ).sum () 164n_unlabeled = df ['seed_label' ].isna ().sum () 165 166f"Seed labeling results:" ) 167f" Positive seeds (hallucinations): { n_pos_seeds } ( { n_pos_seeds / len ( df ):.1% } )" ) 168f" Negative seeds (valid): { n_neg_seeds } ( { n_neg_seeds / len ( df ):.1% } )" ) 169f" Unlabeled: { n_unlabeled } ( { n_unlabeled / len ( df ):.1% } )" ) 170 171if n_pos_seeds == 0 or n_neg_seeds == 0 : 172"Warning: Not enough seed labels. Adjusting thresholds might help." ) 173return 174 175# Log all seed data 176pos_seeds = df [df ['seed_label' ]== 1 ] 177neg_seeds = df [df ['seed_label' ]== 0 ] 178 179log_seed_data (pos_seeds ,"Positive" ,"likely hallucinations" ) 180log_seed_data (neg_seeds ,"Negative" ,"likely valid" ) 181 182# Define features (trimmed to remove redundant transformations) 183features = [ 184'avg_logprob' , 185'no_speech_prob' , 186'compression_ratio' , 187'log_duration' , 188'log_sps' , 189'log_raw_duration' , 190'log_raw_sps' , 191'duration_ratio' , 192'logprob_duration_interaction' 193 ] 194 195X = df [features ].values 196 197# Step 2: Train kNN on seed labels 198"\nTraining k-NN classifier on seed labels..." ) 199 200labeled_mask = df ['seed_label' ].notna () 201X_seed = X [labeled_mask ] 202y_seed = df .loc [labeled_mask ,'seed_label' ].values .astype (int ) 203 204# Auto-select k based on seed data size 205n_seed_samples = len (X_seed ) 206optimal_k = min (max (int (np .sqrt (n_seed_samples )),3 ),n_seed_samples // 2 ) 207f"Using k= { optimal_k } neighbors (from { n_seed_samples } seed samples)" ) 208 209# Create pipeline with scaling (important for kNN) 210knn_pipeline = Pipeline ([ 211 ('scale' ,StandardScaler ()), 212 ('knn' ,KNeighborsClassifier ( 213n_neighbors = optimal_k , 214weights = 'distance' # closer neighbors weigh more 215 )) 216 ]) 217 218# --- step 2: train k-NN on seeds ------------------------------- 219cv = StratifiedKFold (n_splits = 5 ,shuffle = True ,random_state = 42 ) 220 221# out-of-fold probas for the seeds 222seed_scores = cross_val_predict ( 223knn_pipeline ,# pipeline defined earlier 224X_seed ,y_seed , 225cv = cv , 226method = "predict_proba" 227 )[:,1 ] 228 229# store the scores for thresholding 230df .loc [labeled_mask ,'knn_score' ]= seed_scores 231 232# finally fit on the full seed set before scoring the rest 233knn_pipeline .fit (X_seed ,y_seed ) 234df .loc [~ labeled_mask ,'knn_score' ]= knn_pipeline .predict_proba ( 235X [~ labeled_mask ])[:,1 ] 236 237# --- step 2 done: kNN scores are in df['knn_score'] --------------------- 238 239# Debug: how are the scores distributed? 240for lbl ,mask in { 241"Positive seeds" :df ['seed_label' ]== 1 , 242"Negative seeds" :df ['seed_label' ]== 0 , 243"Un-labelled" :df ['seed_label' ].isna () 244 }.items (): 245scores = df .loc [mask ,'knn_score' ] 246if scores .empty : 247continue 248f" { lbl :15s } | n= { len ( scores ):4d } min= { scores . min ():.3f } " 249f"25%= { scores . quantile ( .25 ):.3f } median= { scores . median ():.3f } " 250f"75%= { scores . quantile ( .75 ):.3f } max= { scores . max ():.3f } " ) 251# blank line for readability 252 253# Step 3: derive a threshold from the seed scores 254"Applying threshold to segment scores..." ) 255 256neg_seed_scores = df .loc [df ['seed_label' ]== 0 ,'knn_score' ] 257pos_seed_scores = df .loc [df ['seed_label' ]== 1 ,'knn_score' ] 258 259max_neg = neg_seed_scores .max () 260min_pos = pos_seed_scores .min () 261 262if min_pos > max_neg : 263# clear separation – use the midpoint 264threshold = (max_neg + min_pos )/ 2 265reason = "mid-point between max-neg and min-pos" 266else : 267# fallback to percentile rule, but ensure it’s >0 268threshold = np .percentile (neg_seed_scores ,95 ) 269if threshold <= 0 : 270threshold = 1e-3 271reason = "95th percentile of negative seeds" 272 273f"\nChosen threshold: { threshold :.3f } ( { reason } )" ) 274 275df ['is_hallucination' ]= (df ['knn_score' ]>= threshold ).astype (int ) 276 277# Print results 278n_hallucinations = df ['is_hallucination' ].sum () 279f"\nDetected hallucinations: { n_hallucinations } ( { n_hallucinations / len ( df ):.1% } )" ) 280 281# Step 4: Train final gradient boosting model on kNN labels 282"\nTraining final Gradient Boosting classifier..." ) 283 284X_final = df [features ] 285y_final = df ['is_hallucination' ] 286 287# Split data 288X_train ,X_test ,y_train ,y_test = train_test_split ( 289X_final ,y_final ,test_size = 0.3 ,stratify = y_final ,random_state = 42 290 ) 291 292# Train model 293model = GradientBoostingClassifier ( 294n_estimators = 80 , 295max_depth = 3 , 296learning_rate = 0.05 , 297random_state = 42 298 ) 299model .fit (X_train ,y_train ) 300 301# Evaluate 302y_pred = model .predict (X_test ) 303y_proba_gb = model .predict_proba (X_test )[:,1 ] 304 305"\nFinal Model Performance:" ) 306classification_report (y_test ,y_pred )) 307 308# Confusion matrix 309tn ,fp ,fn ,tp = confusion_matrix (y_test ,y_pred ).ravel () 310tpr = tp / (tp + fn )if (tp + fn )> 0 else 0.0 311fpr = fp / (fp + tn )if (fp + tn )> 0 else 0.0 312 313f"\nDetection rate (TPR): { tpr :.1% } " ) 314f"False positive rate (FPR): { fpr :.1% } " ) 315 316# Feature importance 317"\nFeature Importance:" ) 318for feat ,imp in sorted (zip (features ,model .feature_importances_ ), 319key = lambda x :x [1 ],reverse = True ): 320f" { feat } : { imp :.3f } " ) 321 322# Show example detections 323hallucination_examples = df [df ['is_hallucination' ]== 1 ].head (10 ) 324f"\nExample detected hallucinations:" ) 325for _ ,seg in hallucination_examples .iterrows (): 326f" Score= { seg [ 'knn_score' ]:.3f } , text=' { seg [ 'text' ] } '" ) 327 328non_hallucination_examples = df [df ['is_hallucination' ]== 0 ].head (10 ) 329f"\nExample detected non-hallucinations:" ) 330for _ ,seg in non_hallucination_examples .iterrows (): 331f" Score= { seg [ 'knn_score' ]:.3f } , text=' { seg [ 'text' ] } '" ) 332 333# --- after training the GB model --- 334gb_scores = model .predict_proba (X_final )[:,1 ] 335 336# choose threshold on GB scores, e.g. same 95-percentile rule 337neg_scores = gb_scores [df ['seed_label' ]== 0 ] 338threshold = np .percentile (neg_scores ,95 ) 339f"\nPost-training threshold: { threshold :.3f } " ) 340 341# Save model 342model_dir = Path ("Models" ) 343model_dir .mkdir (exist_ok = True ) 344 345model_bundle = { 346"model" :model , 347"threshold" :threshold , 348"features" :features , 349 } 350 351output_path = model_dir / "thankyou_filter_gb.pkl" 352joblib .dump (model_bundle ,output_path ) 353f"\nModel saved to: { output_path } " ) 354 355if __name__ == "__main__" : 356main ()