yum-slop/TaSTT

Free self-hosted STT for VRChat.

git clone https://git.yummers.dev/yum-slop/TaSTT

yumDrop turbo; use old logic when no_speech ts availablebce0853

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