File size: 21,446 Bytes
3cc173b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
"""

Applicability Domain Setup for Your Mixture DCN Model



Customized for your model that uses:

- InChI strings (not SMILES)

- 2-11 component mixtures

- Mole fractions

- GNN architecture from solvation_predictor

"""

import torch
import numpy as np
import pandas as pd
from typing import List, Tuple
from pathlib import Path
import sys
import os



from applicability_domain import ApplicabilityDomainChecker


class MixtureDCNEmbeddingExtractor:
    """

    Extract embeddings from your trained mixture DCN model ENSEMBLE.

    

    Uses all 10 models and averages their embeddings for more robust representation.

    """
    
    def __init__(self, model_dir: str, device='cpu'):
        """

        Args:

            model_dir: Directory containing all model .pt files

            device: 'cpu', 'cuda', or 'mps'

        """
        self.device = torch.device(device)
        
        # Import model loading function (aliases already set up above)
        from core.predictors.mixture.solvation_predictor.train.train import load_checkpoint, create_logger
        
        # Find all model files
        model_dir_path = Path(model_dir)
        self.model_files = sorted([f for f in model_dir_path.glob("*.pt")])
        
        if len(self.model_files) == 0:
            raise ValueError(f"No .pt model files found in {model_dir}")
        
        print(f"Found {len(self.model_files)} models in ensemble")
        
        # Initialize containers
        self.models = []
        self.hooks = []  # Initialize BEFORE loading models to prevent AttributeError
        self.embeddings = []
        
        # Load all models
        logging_obj = create_logger("embedding_extractor", ".")
        
        for i, model_path in enumerate(self.model_files):
            print(f"  Loading model {i+1}/{len(self.model_files)}: {model_path.name}")
            
            args = self._create_model_args(str(model_path))
            model = load_checkpoint(str(model_path), args, logger=logging_obj)
            model.to(self.device)
            model.eval()
            
            self.models.append(model)
        
        # Register hooks on all models
        self._register_hooks()
        
        print(f"βœ“ Loaded {len(self.models)} models")
        print(f"  Device: {self.device}")
    
    def _create_model_args(self, model_path, device=None):
        """Create minimal args needed for model loading / DataPoint / DataTensor."""
        _device = device  # capture for closure

        class ModelArgs:
            def __init__(self):
                self.model_path = model_path
                self.cuda = torch.cuda.is_available() or torch.backends.mps.is_available()

                # Model architecture
                self.depth = 4
                self.mpn_hidden = 200
                self.mpn_dropout = 0.0
                self.mpn_activation = "LeakyReLU"
                self.mpn_bias = False
                self.aggregation = "mean"

                self.ffn_hidden = 500
                self.ffn_num_layers = 4
                self.ffn_dropout = 0.0
                self.ffn_activation = "LeakyReLU"
                self.ffn_bias = True

                self.num_targets = 1
                self.attention = False
                self.postprocess = False
                self.num_features = 0
                self.f_mol_size = 2
                self.num_mols = 11
                self.max_num_mols = 11

                # Additional attributes from inp.py
                self.property = "solvation"
                self.solute = False
                self.add_hydrogens_to_solvent = False
                self.uncertainty = False
                self.ensemble_variance = False
                self.mix = False
                self.morgan_fingerprint = "None"
                self.morgan_bits = 16
                self.morgan_radius = 2

                # Device β€” propagated so DataPoint/DataTensor match the model's device
                self.device = _device

                # Attention parameters
                self.att_hidden = 200
                self.att_dropout = 0.0
                self.att_bias = False
                self.att_activation = "ReLU"
                self.att_normalize = "sigmoid"
                self.att_first_normalize = False

        return ModelArgs()

    @classmethod
    def from_models(cls, models: list, model_dir: str = ".", device=None):
        """

        Create an extractor from already-loaded models (avoids loading them twice).



        Args:

            models: List of loaded PyTorch model objects (e.g. from MixtureDCNPredictor)

            model_dir: Path used only for args metadata (no files are read)

            device: If None, inferred from the first model's parameters

        """
        instance = cls.__new__(cls)

        # Infer device from the model if not specified
        if device is None:
            try:
                device = next(models[0].parameters()).device
            except StopIteration:
                device = torch.device('cpu')

        instance.device = torch.device(device) if not isinstance(device, torch.device) else device
        instance.model_files = [Path(model_dir)]  # Needed only for _create_model_args path metadata
        instance.models = list(models)
        instance.hooks = []
        instance.embeddings = []

        instance._register_hooks()

        print(f"βœ“ Embedding extractor reusing {len(instance.models)} pre-loaded models")
        print(f"  Device: {instance.device}")
        return instance
    
                

    
    def _register_hooks(self):
        """Register hooks on all models to capture embeddings."""
        for model_idx, model in enumerate(self.models):
            # Create a separate embeddings list for each model
            model_embeddings = []
            
            # Pre-hook: captures the INPUT to a module (before any transformation).
            # Used on model.ffn to get the mole-fraction-weighted mixture vector
            # produced by mixture_forward() β€” one vector per mixture, not per molecule.
            def create_pre_hook_fn(emb_list):
                def hook_fn(module, input):
                    inp = input[0] if isinstance(input, tuple) else input
                    emb_list.append(inp.detach().cpu())
                return hook_fn

            # Register hook - prefer ffn pre-hook (true mixture-level representation)
            hook = None
            if hasattr(model, 'ffn'):
                # Captures the mole-fraction-weighted sum of MPN embeddings:
                # shape (N_mixtures, ffn_input_size) β€” fires once per forward pass
                hook = model.ffn.register_forward_pre_hook(
                    create_pre_hook_fn(model_embeddings)
                )
            elif hasattr(model, 'encoder'):
                hook = model.encoder.register_forward_hook(
                    create_pre_hook_fn(model_embeddings)
                )
            elif hasattr(model, 'predictor'):
                layers = list(model.children())
                hook = layers[-2].register_forward_hook(
                    create_pre_hook_fn(model_embeddings)
                )

            if hook is None:
                available = [name for name, _ in model.named_children()]
                raise RuntimeError(
                    f"Could not find a layer to hook on model {model_idx}. "
                    f"Available top-level children: {available}"
                )

            self.hooks.append(hook)
            self.embeddings.append(model_embeddings)
        
        print(f"  Registered hooks on {len(self.hooks)} models")
    
    def extract_embeddings_from_mixtures(self, 

                                        mixture_data: List[dict]) -> np.ndarray:
        """

        Extract embeddings from mixture dictionaries using ENSEMBLE AVERAGING.

        

        Args:

            mixture_data: List of dicts with keys:

                - 'inchis': List of InChI strings

                - 'fractions': List of mole fractions (N-1 for N components)

        

        Returns:

            embeddings: numpy array of shape (n_mixtures, embedding_dim)

                       (averaged across all models in ensemble)

        """
        from core.predictors.mixture.solvation_predictor.data.data import (
            DataPoint, DatapointList, MolencoderDatabase, DataTensor
        )

        # Clear previous embeddings
        for emb_list in self.embeddings:
            emb_list.clear()

        args = self._create_model_args(str(self.model_files[0]), device=self.device)

        with torch.no_grad():
            # Build DataPoints - one shared MolencoderDatabase caches mol graphs
            mol_encoder_db = MolencoderDatabase()
            datapoints = []
            for mix in mixture_data:
                dp = DataPoint(
                    smiles=mix['inchis'],  # Model accepts InChI in smiles field
                    targets=[0.0],         # Dummy target
                    features=[],
                    molefracs=mix['fractions'],
                    inp=args,
                    mol_encoders=mol_encoder_db,
                )
                datapoints.append(dp)

            data = DatapointList(datapoints)

            # Build tensors list: one DataTensor per molecule position (same as train.py)
            mol_encodings = [[] for _ in range(args.max_num_mols)]
            for d in datapoints:
                encoders = d.get_mol_encoder()
                # Pad short mixtures by repeating the first encoder
                while len(encoders) < args.max_num_mols:
                    encoders.append(encoders[0])
                for pos, enc in enumerate(encoders):
                    mol_encodings[pos].append(enc)

            tensors = [
                DataTensor(mol_enc_list, args, property=args.property)
                for mol_enc_list in mol_encodings
            ]

            # Forward pass through ALL models (hooks will capture MPN embeddings)
            for model in self.models:
                _ = model(data, tensors)
        
        # Collect embeddings from all models
        all_model_embeddings = []
        
        for model_idx, emb_list in enumerate(self.embeddings):
            if len(emb_list) > 0:
                # Concatenate batches for this model
                model_emb = torch.cat(emb_list, dim=0)
                all_model_embeddings.append(model_emb)
        
        if len(all_model_embeddings) == 0:
            raise RuntimeError("No embeddings captured! Check hook registration.")
        
        # Stack and average across models
        # Shape: (n_models, n_samples, embedding_dim)
        stacked_embeddings = torch.stack(all_model_embeddings, dim=0)
        
        # Average across models
        # Shape: (n_samples, embedding_dim)
        averaged_embeddings = stacked_embeddings.mean(dim=0)
        
        print(f"  Extracted embeddings from {len(all_model_embeddings)} models")
        print(f"  Shape per model: {all_model_embeddings[0].shape}")
        print(f"  Averaged shape: {averaged_embeddings.shape}")
        
        return averaged_embeddings.numpy()
    
    def __del__(self):
        """Remove all hooks when done."""
        for hook in self.hooks:
            hook.remove()


def load_training_data_for_ad(csv_path: str, max_samples: int = None) -> List[dict]:
    """

    Load training mixtures in format needed for embedding extraction.

    

    Args:

        csv_path: Path to formatted training CSV

        max_samples: Maximum number of samples to load (None = all)

    

    Returns:

        List of mixture dictionaries

    """
    # Try different encodings (same as your prediction script)
    encodings = ['utf-8', 'latin-1', 'iso-8859-1', 'cp1252']
    df = None
    
    for encoding in encodings:
        try:
            print(f"  Trying encoding: {encoding}")
            df = pd.read_csv(csv_path, encoding=encoding)
            print(f"  βœ“ Successfully read with {encoding} encoding")
            break
        except UnicodeDecodeError:
            continue
    
    if df is None:
        raise ValueError(f"Could not read CSV with any of these encodings: {encodings}")
    
    print(f"  Loaded {len(df)} rows from CSV")
    
    mixtures = []
    
    for idx, row in df.iterrows():
        if max_samples and idx >= max_samples:
            break
        
        # Extract InChI strings (try multiple column name formats)
        inchis = []
        
        # Format 1: "fuel1 inchi", "fuel2 inchi", etc. (with space)
        for i in range(1, 12):
            col = f'fuel{i} inchi'
            if col in df.columns and pd.notna(row.get(col)):
                inchi = str(row[col]).strip()
                if inchi and inchi != 'nan':
                    inchis.append(inchi)
        
        # Format 2: "fuel1_inchi", "fuel2_inchi", etc. (with underscore)
        if len(inchis) == 0:
            for i in range(1, 12):
                col = f'fuel{i}_inchi'
                if col in df.columns and pd.notna(row.get(col)):
                    inchi = str(row[col]).strip()
                    if inchi and inchi != 'nan':
                        inchis.append(inchi)
        
        if len(inchis) == 0:
            continue
        
        # Extract mole fractions (N-1 for N components)
        fractions = []
        
        # Format 1: "molar fraction fuel 1", etc.
        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]))
        
        # Format 2: "frac_fuel1 (molar)", etc. (from formatted data)
        if len(fractions) == 0:
            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]))
        
        # Only add if we have fractions
        if len(fractions) == len(inchis) - 1:
            mixtures.append({
                'inchis': inchis,
                'fractions': fractions
            })
    
    print(f"  βœ“ Extracted {len(mixtures)} valid mixtures")
    
    return mixtures


def train_ad_checker_for_mixture_model(

    training_csv: str,

    model_dir: str,  # Changed from model_path to model_dir

    output_path: str = "models/mixture_ad_checker.pkl",

    nu: float = 0.02,

    device: str = 'cpu'

):
    """

    Train AD checker for your mixture DCN model ENSEMBLE.

    

    Args:

        training_csv: Path to formatted training data CSV

        model_dir: Directory containing all .pt model files

        output_path: Where to save AD checker

        nu: Outlier fraction for One-Class SVM

        device: 'cpu', 'cuda', or 'mps'

    """
    print("="*70)
    print("TRAINING APPLICABILITY DOMAIN CHECKER FOR MIXTURE DCN MODEL")
    print("="*70)
    
    # Step 1: Load training data
    print("\nStep 1: Loading training data...")
    mixtures = load_training_data_for_ad(training_csv)
    print(f"βœ“ Loaded {len(mixtures)} training mixtures")
    
    # Step 2: Create embedding extractor with ENSEMBLE
    print("\nStep 2: Loading model ensemble and creating embedding extractor...")
    extractor = MixtureDCNEmbeddingExtractor(model_dir, device=device)
    
    # Step 3: Extract embeddings (averaged across all models)
    print("\nStep 3: Extracting embeddings from training set...")
    print("  (This may take several minutes with 10 models...)")
    
    train_embeddings = extractor.extract_embeddings_from_mixtures(mixtures)
    print(f"βœ“ Extracted embeddings: shape {train_embeddings.shape}")
    print(f"  (Averaged across {len(extractor.models)} models)")
    
    # Step 4: Train One-Class SVM
    print("\nStep 4: Training One-Class SVM...")
    
    ad_checker = ApplicabilityDomainChecker(
        nu=nu,
        kernel='rbf',
        gamma='scale'
    )
    
    ad_checker.fit(train_embeddings)
    
    # Step 5: Validate on training set
    print("\nStep 5: Validating on training set...")
    
    train_in_domain = ad_checker.is_in_domain(train_embeddings)
    train_confidence = ad_checker.get_confidence_scores(train_embeddings)
    
    print(f"  In domain: {train_in_domain.sum()}/{len(train_in_domain)} ({train_in_domain.mean()*100:.1f}%)")
    print(f"  Mean confidence: {train_confidence.mean():.1f}%")
    print(f"  Confidence range: [{train_confidence.min():.1f}, {train_confidence.max():.1f}]")
    
    # Step 6: Save AD checker
    print("\nStep 6: Saving AD checker...")
    
    output_dir = Path(output_path).parent
    output_dir.mkdir(parents=True, exist_ok=True)
    
    ad_checker.save(output_path)
    
    print("\n" + "="*70)
    print("TRAINING COMPLETE!")
    print("="*70)
    print(f"\nAD checker saved to: {output_path}")
    print(f"Embedding extractor uses: {len(extractor.models)} models from {model_dir}")
    print("\nNext steps:")
    print("  1. Use this AD checker during evolution")
    print("  2. See mixture_evolution_with_ad.py for integration")
    print("="*70)
    
    return ad_checker, extractor


# =============================================================================
# MAIN SCRIPT
# =============================================================================

if __name__ == "__main__":
    # Configuration
    TRAINING_CSV = "data/database/mixture_training_dataset.csv"  # Your formatted training data
    MODEL_DIR = "models/mixture/dcn"  # Directory with all 10 models
    OUTPUT_PATH = "models/mixture_ad_checker.pkl"
    
    # Device selection
    if torch.cuda.is_available():
        device = 'cuda'
    elif torch.backends.mps.is_available():
        device = 'mps'
    else:
        device = 'cpu'
    
    print(f"Using device: {device}")
    
    # Check if model directory exists
    if not Path(MODEL_DIR).exists():
        print(f"\nβœ— ERROR: Model directory not found: {MODEL_DIR}")
        print("Please update MODEL_DIR to point to your DCN models directory")
        sys.exit(1)
    
    model_files = list(Path(MODEL_DIR).glob("*.pt"))
    print(f"Found {len(model_files)} model files in {MODEL_DIR}")
    
    if len(model_files) == 0:
        print(f"\nβœ— ERROR: No .pt model files found in {MODEL_DIR}")
        sys.exit(1)
    
    # Train AD checker
    try:
        ad_checker, extractor = train_ad_checker_for_mixture_model(
            training_csv=TRAINING_CSV,
            model_dir=MODEL_DIR,  # Now using directory
            output_path=OUTPUT_PATH,
            nu=0.02,  # 2% outliers
            device=device
        )
        
        print("\nβœ“ Setup complete! You can now use the AD checker in your evolution code.")
        
    except FileNotFoundError as e:
        print(f"\nβœ— ERROR: File not found")
        print(f"  {e}")
        print("\nPlease update the paths at the bottom of this script:")
        print(f"  TRAINING_CSV = '{TRAINING_CSV}'")
        print(f"  MODEL_DIR = '{MODEL_DIR}'")
        
    except Exception as e:
        print(f"\nβœ— ERROR: {type(e).__name__}")
        print(f"  {e}")
        import traceback
        traceback.print_exc()


# =============================================================================
# USAGE IN EVOLUTION
# =============================================================================

"""

Once trained, use in your mixture evolution like this:



from mixture_evolution_with_ad import MixtureAwareMolecularEvolutionWithAD



# Load AD checker

ad_checker = ApplicabilityDomainChecker.load('models/mixture_ad_checker.pkl')



# Load embedding extractor (loads ALL 10 models)

extractor = MixtureDCNEmbeddingExtractor(

    'models/mixture/dcn',  # Directory!

    device='cpu'

)



# During evolution, for each batch of new additives:

new_mixtures = [

    {

        'inchis': [additive_inchi] + base_fuel_inchis,

        'fractions': [additive_fraction] + base_fuel_fractions

    }

    for additive_inchi in new_additive_inchis

]



# Extract embeddings (averaged across 10 models!)

embeddings = extractor.extract_embeddings_from_mixtures(new_mixtures)



# Check AD

in_domain = ad_checker.is_in_domain(embeddings)

confidence = ad_checker.get_confidence_scores(embeddings)



# Filter

for i, additive in enumerate(new_additives):

    if not in_domain[i]:

        print(f"{additive}: OUT OF AD! (confidence: {confidence[i]:.1f}%)")

        continue  # Skip this molecule

    

    # Use this molecule (high confidence prediction)

    ...





WHY USE ALL 10 MODELS FOR EMBEDDINGS?

-------------------------------------



Ensemble averaging gives MORE ROBUST embeddings:

- Single model: Embedding might be noisy

- 10 models averaged: Smoother, more stable representation

- Better AD detection: Less false positives/negatives



The embeddings capture the "consensus" of what all 10 models learned!

"""