Spaces:
Running
Running
| """ | |
| Mixture Embedding Extraction + One-Class SVM Training | |
| """ | |
| import numpy as np | |
| import torch | |
| import pandas as pd | |
| import pickle | |
| import matplotlib | |
| matplotlib.use('Agg') # headless backend (no GUI) | |
| import matplotlib.pyplot as plt | |
| import matplotlib.gridspec as gridspec | |
| from sklearn.svm import OneClassSVM | |
| from sklearn.preprocessing import StandardScaler | |
| from sklearn.decomposition import PCA | |
| import math | |
| # ============================================================================= | |
| # STEP 1: LOAD MODEL | |
| # ============================================================================= | |
| def load_predictor(model_dir): | |
| from core.predictors.mixture.mixture_dcn_predictor import MixtureDCNPredictor | |
| predictor = MixtureDCNPredictor(model_dir=model_dir) | |
| predictor._initialize_models() | |
| print(f"✓ Loaded {len(predictor.models)} models") | |
| return predictor | |
| # ============================================================================= | |
| # STEP 2: LOAD CSV | |
| # ============================================================================= | |
| def load_csv(csv_path): | |
| for encoding in ['utf-8', 'latin-1', 'iso-8859-1', 'cp1252']: | |
| try: | |
| df = pd.read_csv(csv_path, encoding=encoding) | |
| print(f"✓ CSV loaded with {encoding}") | |
| return df | |
| except Exception: | |
| continue | |
| raise ValueError("Failed to load CSV with any encoding") | |
| # ============================================================================= | |
| # STEP 3: CONVERT TO MIXTURES | |
| # ============================================================================= | |
| def _detect_format(df): | |
| """Return 'training' or 'seed' based on column naming convention.""" | |
| if 'fuel1 inchi' in df.columns: | |
| return 'training' # e.g. 'fuel1 inchi', 'molar fraction fuel 1' | |
| if 'fuel1_inchi' in df.columns: | |
| return 'seed' # e.g. 'fuel1_inchi', 'frac_fuel1 (molar)' | |
| raise ValueError( | |
| f"Unrecognised column format. Columns found: {df.columns.tolist()}" | |
| ) | |
| def _parse_row_training(row, df): | |
| """Parser for mixture_training_dataset.csv format.""" | |
| inchis = [] | |
| for i in range(1, 12): | |
| col = f'fuel{i} inchi' | |
| if col in df.columns and pd.notna(row.get(col)): | |
| val = str(row[col]).strip().strip('"').strip("'") | |
| if val and val != 'nan': | |
| inchis.append(val) | |
| if not inchis: | |
| return None | |
| fractions = [] | |
| for i in range(1, len(inchis)): | |
| col = f'molar fraction fuel {i}' | |
| if col in df.columns and pd.notna(row.get(col)): | |
| fractions.append(float(row[col])) | |
| else: | |
| break | |
| if len(fractions) == len(inchis) - 1: | |
| return {'inchis': inchis, 'fractions': fractions} | |
| return None | |
| def _parse_row_seed(row, df): | |
| """Parser for formatted_mixtures.csv format. | |
| Columns: fuel1_inchi, fuel2_inchi, ..., frac_fuel1 (molar), frac_fuel2 (molar) | |
| Stores N-1 fractions (last component fraction is implicit = 1 - sum). | |
| """ | |
| inchis = [] | |
| for i in range(1, 12): | |
| col = f'fuel{i}_inchi' | |
| if col in df.columns and pd.notna(row.get(col)): | |
| val = str(row[col]).strip().strip('"').strip("'") | |
| if val and val != 'nan': | |
| inchis.append(val) | |
| if not inchis: | |
| return None | |
| # Collect all available fractions (may be N-1 or fewer) | |
| fractions = [] | |
| for i in range(1, len(inchis)): | |
| col = f'frac_fuel{i} (molar)' | |
| if col in df.columns and pd.notna(row.get(col)): | |
| fractions.append(float(row[col])) | |
| else: | |
| break | |
| if len(fractions) == len(inchis) - 1: | |
| return {'inchis': inchis, 'fractions': fractions} | |
| return None | |
| def build_mixtures(df): | |
| fmt = _detect_format(df) | |
| parser = _parse_row_training if fmt == 'training' else _parse_row_seed | |
| mixtures = [] | |
| for _, row in df.iterrows(): | |
| result = parser(row, df) | |
| if result is not None: | |
| mixtures.append(result) | |
| print(f"✓ Built {len(mixtures)} mixtures (format: {fmt})") | |
| return mixtures | |
| # ============================================================================= | |
| # STEP 4: EXTRACT EMBEDDINGS (ENSEMBLE AVERAGE) | |
| # ============================================================================= | |
| def extract_embeddings(predictor, mixtures, batch_size=50): | |
| from core.predictors.mixture.solvation_predictor.data.data import ( | |
| DataPoint, DatapointList, MolencoderDatabase, DataTensor | |
| ) | |
| all_model_embeddings = [] | |
| for model_idx, model in enumerate(predictor.models): | |
| print(f"\n Extracting from model {model_idx + 1}/{len(predictor.models)}") | |
| model.eval() | |
| captured = [] | |
| def hook_fn(module, input, output): | |
| captured.append(input[0].detach().cpu()) | |
| hook = model.ffn.register_forward_hook(hook_fn) | |
| for i in range(0, len(mixtures), batch_size): | |
| batch = mixtures[i:i + batch_size] | |
| datapoints = [] | |
| mol_db = MolencoderDatabase() | |
| for mix in batch: | |
| try: | |
| dp = DataPoint( | |
| smiles=mix['inchis'], | |
| targets=[0.0], | |
| features=[], | |
| molefracs=mix['fractions'], | |
| inp=predictor.args, | |
| mol_encoders=mol_db | |
| ) | |
| datapoints.append(dp) | |
| except Exception: | |
| continue | |
| if len(datapoints) == 0: | |
| continue | |
| data = DatapointList(datapoints) | |
| mol_encodings = [[] for _ in range(predictor.args.max_num_mols)] | |
| tensors = [] | |
| for m in range(len(mol_encodings)): | |
| for d in datapoints: | |
| enc = d.get_mol_encoder() | |
| if len(enc) < predictor.args.max_num_mols: | |
| enc += [enc[0]] * (predictor.args.max_num_mols - len(enc)) | |
| mol_encodings[m].append(enc[m]) | |
| tensors.append(DataTensor( | |
| mol_encodings[m], predictor.args, | |
| property=predictor.args.property | |
| )) | |
| with torch.no_grad(): | |
| _ = model(data, tensors) | |
| hook.remove() | |
| model_emb = torch.cat(captured, dim=0).numpy() | |
| print(f" → Shape: {model_emb.shape}") | |
| all_model_embeddings.append(model_emb) | |
| X = np.mean(all_model_embeddings, axis=0) | |
| print(f"\n✓ Final embedding shape: {X.shape}") | |
| print(f"✓ Embedding std: {np.std(X):.4f}") | |
| return X | |
| # ============================================================================= | |
| # STEP 5: TRAIN ONE-CLASS SVM WITH FIXED HYPERPARAMETER SELECTION | |
| # ============================================================================= | |
| def train_ocsvm(X): | |
| scaler = StandardScaler() | |
| X_scaled = scaler.fit_transform(X) | |
| # Restricted to what visual inspection confirmed works well: | |
| # gamma=scale is the only well-behaved option for this embedding scale. | |
| # nu range kept tight to favour conservative applicability domains. | |
| nu_candidates = [0.02, 0.05] | |
| gamma_candidates = ['scale'] | |
| results = [] | |
| print("\nGrid search results:") | |
| print(f" {'nu':>6} {'gamma':>8} {'actual_out':>10} {'deviation':>10} {'score_mean':>11} {'score_penalty':>13}") | |
| for nu in nu_candidates: | |
| for gamma in gamma_candidates: | |
| svm = OneClassSVM(kernel='rbf', nu=nu, gamma=gamma) | |
| svm.fit(X_scaled) | |
| preds = svm.predict(X_scaled) | |
| scores = svm.decision_function(X_scaled) | |
| frac_out = (preds == -1).mean() | |
| # Primary criterion: how close is actual outlier rate to target nu? | |
| deviation = abs(frac_out - nu) | |
| # Secondary criterion: penalise boundaries that are too loose | |
| # (high mean score) or incorrectly cutting into data (very negative). | |
| # A well-calibrated SVM has scores centred near zero. | |
| score_penalty = abs(scores.mean()) | |
| # Combined score — deviation dominates; score_penalty breaks ties | |
| combined = deviation + 0.1 * score_penalty | |
| results.append({ | |
| 'svm': svm, | |
| 'nu': nu, | |
| 'gamma': gamma, | |
| 'frac_out': frac_out, | |
| 'deviation': deviation, | |
| 'score_mean': round(float(scores.mean()), 4), | |
| 'score_std': round(float(scores.std()), 4), | |
| 'score_penalty': score_penalty, | |
| 'combined': combined, | |
| 'scores': scores, | |
| }) | |
| print(f" {nu:>6.2f} {str(gamma):>8} " | |
| f"{frac_out*100:>9.1f}% " | |
| f"{deviation:>10.4f} " | |
| f"{scores.mean():>11.4f} " | |
| f"{score_penalty:>13.4f}") | |
| # Select by combined score — tightest well-calibrated boundary wins | |
| best = min(results, key=lambda r: r['combined']) | |
| print(f"\n Best → nu={best['nu']}, gamma={best['gamma']}, " | |
| f"actual_out={best['frac_out']*100:.1f}%") | |
| return best, scaler, X_scaled, results | |
| # ============================================================================= | |
| # STEP 6: PLOT BOUNDARY COMPARISON | |
| # ============================================================================= | |
| def plot_ocsvm_grid(X_scaled, results, best, save_path="ocsvm_grid.png"): | |
| print("\nGenerating boundary comparison plot...") | |
| pca = PCA(n_components=2, random_state=42) | |
| X_2d = pca.fit_transform(X_scaled) | |
| var_exp = pca.explained_variance_ratio_ | |
| pad = 0.5 | |
| xx, yy = np.meshgrid( | |
| np.linspace(X_2d[:, 0].min() - pad, X_2d[:, 0].max() + pad, 200), | |
| np.linspace(X_2d[:, 1].min() - pad, X_2d[:, 1].max() + pad, 200), | |
| ) | |
| grid_high = pca.inverse_transform(np.c_[xx.ravel(), yy.ravel()]) | |
| n_models = len(results) | |
| cols = min(3, n_models) | |
| rows = math.ceil(n_models / cols) | |
| fig, axes = plt.subplots(rows, cols, figsize=(5 * cols, 4 * rows)) | |
| axes = np.array(axes).flatten() | |
| for idx, r in enumerate(results): | |
| ax = axes[idx] | |
| svm = r['svm'] | |
| Z = svm.decision_function(grid_high).reshape(xx.shape) | |
| cf = ax.contourf(xx, yy, Z, levels=20, cmap='RdYlGn', alpha=0.6) | |
| ax.contour(xx, yy, Z, levels=[0], colors='black', | |
| linewidths=1.5, linestyles='--') | |
| fig.colorbar(cf, ax=ax, shrink=0.8, label='decision score') | |
| preds = svm.predict(X_scaled) | |
| inliers = preds == 1 | |
| ax.scatter(X_2d[inliers, 0], X_2d[inliers, 1], | |
| s=10, alpha=0.4, color='steelblue', label='inlier') | |
| ax.scatter(X_2d[~inliers, 0], X_2d[~inliers, 1], | |
| s=20, alpha=0.9, color='crimson', marker='x', | |
| linewidths=1.5, label='outlier') | |
| ax.set_title( | |
| f"nu={r['nu']}, γ={r['gamma']}\n" | |
| f"out={r['frac_out']*100:.1f}% " | |
| f"score_mean={r['score_mean']:.3f}", | |
| fontsize=9 | |
| ) | |
| ax.set_xlabel(f"PC1 ({var_exp[0]*100:.1f}% var)", fontsize=8) | |
| ax.set_ylabel(f"PC2 ({var_exp[1]*100:.1f}% var)", fontsize=8) | |
| ax.legend(fontsize=7) | |
| if r['nu'] == best['nu'] and r['gamma'] == best['gamma']: | |
| for spine in ax.spines.values(): | |
| spine.set_edgecolor('#1D9E75') | |
| spine.set_linewidth(2.5) | |
| for i in range(n_models, len(axes)): | |
| fig.delaxes(axes[i]) | |
| fig.suptitle( | |
| "One-class SVM boundary comparison", | |
| fontsize=13, fontweight='bold' | |
| ) | |
| plt.tight_layout() | |
| plt.savefig(save_path, dpi=150, bbox_inches='tight') | |
| print(f"✓ Saved plot → {save_path}") | |
| # ============================================================================= | |
| # STEP 7: SAVE MODEL | |
| # ============================================================================= | |
| def save_model(best, scaler, X, path): | |
| # 5th-percentile score as a soft threshold for inference-time flagging | |
| threshold = float(np.percentile(best['scores'], 5)) | |
| with open(path, 'wb') as f: | |
| pickle.dump({ | |
| 'svm': best['svm'], | |
| 'scaler': scaler, | |
| 'nu': best['nu'], | |
| 'gamma': best['gamma'], | |
| 'threshold': threshold, | |
| 'embedding_dim': X.shape[1], | |
| }, f) | |
| print(f"\n✓ Saved model to {path}") | |
| print(f" nu={best['nu']}, gamma={best['gamma']}, " | |
| f"threshold (p5)={threshold:.4f}") | |
| # ============================================================================= | |
| # MAIN | |
| # ============================================================================= | |
| def main(): | |
| predictor = load_predictor("models/mixture/dcn") | |
| # --- training dataset --- | |
| print("\n--- Training dataset ---") | |
| df_train = load_csv("data/database/mixture_training_dataset.csv") | |
| mix_train = build_mixtures(df_train) | |
| X_train = extract_embeddings(predictor, mix_train) | |
| print(f" Training embeddings: {X_train.shape}") | |
| # --- seed dataset --- | |
| print("\n--- Seed dataset ---") | |
| df_seed = load_csv("data/database/formatted_mixtures.csv") | |
| mix_seed = build_mixtures(df_seed) | |
| X_seed = extract_embeddings(predictor, mix_seed) | |
| print(f" Seed embeddings: {X_seed.shape}") | |
| # --- combine and deduplicate --- | |
| X = np.unique(np.vstack([X_train, X_seed]), axis=0) | |
| print(f"\n✓ Combined + deduplicated: {X.shape[0]} embeddings " | |
| f"(train={X_train.shape[0]}, seed={X_seed.shape[0]})") | |
| best, scaler, X_scaled, results = train_ocsvm(X) | |
| plot_ocsvm_grid(X_scaled, results, best, save_path="ocsvm_grid.png") | |
| save_model(best, scaler, X, "models/mixture/mixture_ocsvm.pkl") | |
| if __name__ == "__main__": | |
| main() |