GreenGenomicsLab commited on
Commit
37171a0
Β·
verified Β·
1 Parent(s): 9fc25e6

Upload scripts/world_model.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. scripts/world_model.py +596 -0
scripts/world_model.py ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ WorldModel: Joint Environment-Genome Embedding for Productivity Prediction.
4
+
5
+ Architecture:
6
+ - Encoder_E: Environment MLP (env_dim -> 128 -> latent_dim)
7
+ - Encoder_P: PFAM Module MLP (pfam_dim -> 256 -> 128 -> latent_dim)
8
+ - Predictor: Productivity head (latent_dim -> 64 -> 3)
9
+
10
+ Training:
11
+ Loss = VICReg(z_env, z_pfam) + alpha * MSE(Predictor(z_env), bio_targets)
12
+
13
+ Inference (environment-only):
14
+ env -> Encoder_E -> z_env -> Predictor -> productivity (chl-a, POC, NFLH)
15
+
16
+ Designed for:
17
+ - 1,810 ocean samples with 24 environmental variables, 20 PFAM modules, 3 bio targets
18
+ - Spatial block CV (leave-one-basin-out)
19
+ - VICReg non-contrastive alignment (Bardes et al., ICLR 2022)
20
+
21
+ Author: World Model RALPH Loop
22
+ Date: 2026-01-27
23
+ """
24
+
25
+ import sys
26
+ import os
27
+ import torch
28
+ import torch.nn as nn
29
+
30
+ # Import VICReg loss from sibling module
31
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
32
+ from vicreg_loss import VICRegLoss
33
+
34
+
35
+ class EncoderE(nn.Module):
36
+ """Environment encoder MLP.
37
+
38
+ Architecture: env_dim -> 128 -> latent_dim
39
+ Each layer: Linear -> BatchNorm1d -> ReLU -> Dropout
40
+
41
+ Parameters
42
+ ----------
43
+ env_dim : int
44
+ Number of environment input features (default 24).
45
+ latent_dim : int
46
+ Latent embedding dimension (default 16).
47
+ dropout : float
48
+ Dropout probability (default 0.3).
49
+ """
50
+
51
+ def __init__(self, env_dim=24, latent_dim=16, dropout=0.3):
52
+ super().__init__()
53
+ self.env_dim = env_dim
54
+ self.latent_dim = latent_dim
55
+
56
+ self.layers = nn.Sequential(
57
+ # Block 1: env_dim -> 128
58
+ nn.Linear(env_dim, 128),
59
+ nn.BatchNorm1d(128),
60
+ nn.ReLU(),
61
+ nn.Dropout(dropout),
62
+ # Block 2: 128 -> latent_dim
63
+ nn.Linear(128, latent_dim),
64
+ nn.BatchNorm1d(latent_dim),
65
+ nn.ReLU(),
66
+ nn.Dropout(dropout),
67
+ )
68
+
69
+ def forward(self, x):
70
+ """
71
+ Parameters
72
+ ----------
73
+ x : torch.Tensor, shape (N, env_dim)
74
+ Standardized environment features.
75
+
76
+ Returns
77
+ -------
78
+ z_env : torch.Tensor, shape (N, latent_dim)
79
+ Environment embedding.
80
+ """
81
+ return self.layers(x)
82
+
83
+
84
+ class EncoderP(nn.Module):
85
+ """PFAM module encoder MLP.
86
+
87
+ Architecture: pfam_dim -> 256 -> 128 -> latent_dim
88
+ Each layer: Linear -> BatchNorm1d -> ReLU -> Dropout
89
+ Deeper than EncoderE because PFAM modules encode richer combinatorial
90
+ information.
91
+
92
+ Parameters
93
+ ----------
94
+ pfam_dim : int
95
+ Number of PFAM module input features (default 20).
96
+ latent_dim : int
97
+ Latent embedding dimension (default 16).
98
+ dropout : float
99
+ Dropout probability (default 0.3).
100
+ """
101
+
102
+ def __init__(self, pfam_dim=20, latent_dim=16, dropout=0.3):
103
+ super().__init__()
104
+ self.pfam_dim = pfam_dim
105
+ self.latent_dim = latent_dim
106
+
107
+ self.layers = nn.Sequential(
108
+ # Block 1: pfam_dim -> 256
109
+ nn.Linear(pfam_dim, 256),
110
+ nn.BatchNorm1d(256),
111
+ nn.ReLU(),
112
+ nn.Dropout(dropout),
113
+ # Block 2: 256 -> 128
114
+ nn.Linear(256, 128),
115
+ nn.BatchNorm1d(128),
116
+ nn.ReLU(),
117
+ nn.Dropout(dropout),
118
+ # Block 3: 128 -> latent_dim
119
+ nn.Linear(128, latent_dim),
120
+ nn.BatchNorm1d(latent_dim),
121
+ nn.ReLU(),
122
+ nn.Dropout(dropout),
123
+ )
124
+
125
+ def forward(self, x):
126
+ """
127
+ Parameters
128
+ ----------
129
+ x : torch.Tensor, shape (N, pfam_dim)
130
+ Standardized PFAM module features.
131
+
132
+ Returns
133
+ -------
134
+ z_pfam : torch.Tensor, shape (N, latent_dim)
135
+ PFAM module embedding.
136
+ """
137
+ return self.layers(x)
138
+
139
+
140
+ class Predictor(nn.Module):
141
+ """Productivity prediction head.
142
+
143
+ Architecture: input_dim -> 64 -> bio_dim
144
+ Simple head: Linear -> ReLU -> Linear (no BatchNorm/Dropout).
145
+
146
+ Parameters
147
+ ----------
148
+ input_dim : int
149
+ Input dimension (latent_dim for env-only, 2*latent_dim for joint).
150
+ bio_dim : int
151
+ Number of bio-response targets (default 3: chl-a, POC, NFLH).
152
+ """
153
+
154
+ def __init__(self, input_dim=16, bio_dim=3):
155
+ super().__init__()
156
+ self.input_dim = input_dim
157
+ self.bio_dim = bio_dim
158
+
159
+ self.layers = nn.Sequential(
160
+ nn.Linear(input_dim, 64),
161
+ nn.ReLU(),
162
+ nn.Linear(64, bio_dim),
163
+ )
164
+
165
+ def forward(self, z):
166
+ """
167
+ Parameters
168
+ ----------
169
+ z : torch.Tensor, shape (N, input_dim)
170
+ Latent embedding (z_env or [z_env, z_pfam]).
171
+
172
+ Returns
173
+ -------
174
+ y_pred : torch.Tensor, shape (N, bio_dim)
175
+ Predicted productivity (chl-a, POC, NFLH).
176
+ """
177
+ return self.layers(z)
178
+
179
+
180
+ class WorldModel(nn.Module):
181
+ """Joint Environment-Genome Embedding Model.
182
+
183
+ Wraps Encoder_E, Encoder_P, Predictor, and VICRegLoss into a single
184
+ module for training and inference.
185
+
186
+ Training flow:
187
+ env -> Encoder_E -> z_env --|
188
+ |--> VICReg(z_env, z_pfam)
189
+ pfam -> Encoder_P -> z_pfam--|
190
+ |--> Predictor(z_env) -> y_pred
191
+ MSE(y_pred, bio_targets)
192
+
193
+ Inference flow (env-only):
194
+ env -> Encoder_E -> z_env -> Predictor -> productivity
195
+
196
+ Parameters
197
+ ----------
198
+ env_dim : int
199
+ Number of environment input features (default 24).
200
+ pfam_dim : int
201
+ Number of PFAM module input features (default 20).
202
+ bio_dim : int
203
+ Number of bio-response targets (default 3).
204
+ latent_dim : int
205
+ Latent embedding dimension (default 16).
206
+ dropout : float
207
+ Dropout probability (default 0.3).
208
+ lambda_inv : float
209
+ VICReg invariance weight (default 25.0).
210
+ lambda_var : float
211
+ VICReg variance weight (default 25.0).
212
+ lambda_cov : float
213
+ VICReg covariance weight (default 1.0).
214
+ pred_alpha : float
215
+ Weight for productivity prediction loss (default 1.0).
216
+ """
217
+
218
+ def __init__(self, env_dim=24, pfam_dim=20, bio_dim=3, latent_dim=16,
219
+ dropout=0.3, lambda_inv=25.0, lambda_var=25.0,
220
+ lambda_cov=1.0, pred_alpha=1.0):
221
+ super().__init__()
222
+
223
+ self.env_dim = env_dim
224
+ self.pfam_dim = pfam_dim
225
+ self.bio_dim = bio_dim
226
+ self.latent_dim = latent_dim
227
+ self.pred_alpha = pred_alpha
228
+
229
+ # Sub-modules
230
+ self.encoder_e = EncoderE(env_dim, latent_dim, dropout)
231
+ self.encoder_p = EncoderP(pfam_dim, latent_dim, dropout)
232
+ self.predictor = Predictor(latent_dim, bio_dim)
233
+ self.vicreg = VICRegLoss(lambda_inv, lambda_var, lambda_cov)
234
+
235
+ # Store config for serialization
236
+ self.config = {
237
+ 'env_dim': env_dim,
238
+ 'pfam_dim': pfam_dim,
239
+ 'bio_dim': bio_dim,
240
+ 'latent_dim': latent_dim,
241
+ 'dropout': dropout,
242
+ 'lambda_inv': lambda_inv,
243
+ 'lambda_var': lambda_var,
244
+ 'lambda_cov': lambda_cov,
245
+ 'pred_alpha': pred_alpha,
246
+ }
247
+
248
+ def forward(self, env, pfam, bio_targets=None, bio_valid=None):
249
+ """Full training forward pass.
250
+
251
+ Parameters
252
+ ----------
253
+ env : torch.Tensor, shape (N, env_dim)
254
+ Standardized environment features.
255
+ pfam : torch.Tensor, shape (N, pfam_dim)
256
+ Standardized PFAM module features.
257
+ bio_targets : torch.Tensor or None, shape (N, bio_dim)
258
+ Standardized bio-response targets. If None, skip pred loss.
259
+ bio_valid : torch.Tensor or None, shape (N,)
260
+ Boolean mask: True where all bio targets are valid.
261
+ If None and bio_targets given, assume all valid.
262
+
263
+ Returns
264
+ -------
265
+ result : dict
266
+ 'z_env': (N, latent_dim) environment embedding
267
+ 'z_pfam': (N, latent_dim) PFAM module embedding
268
+ 'y_pred': (N, bio_dim) predicted productivity
269
+ 'total_loss': scalar total loss
270
+ 'vicreg_loss': scalar VICReg loss
271
+ 'pred_loss': scalar prediction MSE loss (0 if no targets)
272
+ 'vicreg_components': dict of individual VICReg terms
273
+ """
274
+ # Encode both modalities
275
+ z_env = self.encoder_e(env)
276
+ z_pfam = self.encoder_p(pfam)
277
+
278
+ # Predict productivity from environment embedding
279
+ y_pred = self.predictor(z_env)
280
+
281
+ # Compute VICReg alignment loss
282
+ vicreg_loss, vicreg_components = self.vicreg(z_env, z_pfam)
283
+
284
+ # Compute prediction loss (only on bio_valid samples)
285
+ pred_loss = torch.tensor(0.0, device=env.device)
286
+ if bio_targets is not None:
287
+ if bio_valid is not None:
288
+ valid_mask = bio_valid.bool()
289
+ if valid_mask.sum() > 0:
290
+ pred_loss = nn.functional.mse_loss(
291
+ y_pred[valid_mask], bio_targets[valid_mask]
292
+ )
293
+ else:
294
+ pred_loss = nn.functional.mse_loss(y_pred, bio_targets)
295
+
296
+ # Total loss
297
+ total_loss = vicreg_loss + self.pred_alpha * pred_loss
298
+
299
+ return {
300
+ 'z_env': z_env,
301
+ 'z_pfam': z_pfam,
302
+ 'y_pred': y_pred,
303
+ 'total_loss': total_loss,
304
+ 'vicreg_loss': vicreg_loss,
305
+ 'pred_loss': pred_loss,
306
+ 'vicreg_components': vicreg_components,
307
+ }
308
+
309
+ def encode_env(self, env):
310
+ """Encode environment features to latent space.
311
+
312
+ Parameters
313
+ ----------
314
+ env : torch.Tensor, shape (N, env_dim)
315
+
316
+ Returns
317
+ -------
318
+ z_env : torch.Tensor, shape (N, latent_dim)
319
+ """
320
+ return self.encoder_e(env)
321
+
322
+ def encode_pfam(self, pfam):
323
+ """Encode PFAM module features to latent space.
324
+
325
+ Parameters
326
+ ----------
327
+ pfam : torch.Tensor, shape (N, pfam_dim)
328
+
329
+ Returns
330
+ -------
331
+ z_pfam : torch.Tensor, shape (N, latent_dim)
332
+ """
333
+ return self.encoder_p(pfam)
334
+
335
+ def inference(self, env):
336
+ """Environment-only inference path.
337
+
338
+ Parameters
339
+ ----------
340
+ env : torch.Tensor, shape (N, env_dim)
341
+ Standardized environment features.
342
+
343
+ Returns
344
+ -------
345
+ y_pred : torch.Tensor, shape (N, bio_dim)
346
+ Predicted productivity.
347
+ """
348
+ z_env = self.encoder_e(env)
349
+ return self.predictor(z_env)
350
+
351
+ def count_parameters(self):
352
+ """Count total trainable parameters.
353
+
354
+ Returns
355
+ -------
356
+ int
357
+ Total number of trainable parameters.
358
+ """
359
+ return sum(p.numel() for p in self.parameters() if p.requires_grad)
360
+
361
+ def count_parameters_by_component(self):
362
+ """Count trainable parameters per sub-module.
363
+
364
+ Returns
365
+ -------
366
+ dict
367
+ {'encoder_e': int, 'encoder_p': int, 'predictor': int, 'total': int}
368
+ """
369
+ counts = {}
370
+ for name, module in [('encoder_e', self.encoder_e),
371
+ ('encoder_p', self.encoder_p),
372
+ ('predictor', self.predictor)]:
373
+ counts[name] = sum(p.numel() for p in module.parameters()
374
+ if p.requires_grad)
375
+ counts['total'] = sum(counts.values())
376
+ return counts
377
+
378
+
379
+ def self_test():
380
+ """Run comprehensive self-tests for WorldModel. Returns True if all pass."""
381
+ tests_passed = 0
382
+ tests_total = 0
383
+
384
+ def check(name, condition):
385
+ nonlocal tests_passed, tests_total
386
+ tests_total += 1
387
+ if condition:
388
+ tests_passed += 1
389
+ print(f" PASS: {name}")
390
+ else:
391
+ print(f" FAIL: {name}")
392
+
393
+ print("=" * 70)
394
+ print("WorldModel Self-Tests")
395
+ print("=" * 70)
396
+
397
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
398
+ print(f"Device: {device}")
399
+
400
+ # ── Test 1: Instantiation with default parameters ──
401
+ print("\nTest 1: Instantiation with default parameters")
402
+ model = WorldModel(env_dim=24, pfam_dim=20, bio_dim=3,
403
+ latent_dim=16, dropout=0.3).to(device)
404
+ params = model.count_parameters()
405
+ param_detail = model.count_parameters_by_component()
406
+ print(f" Total parameters: {params:,}")
407
+ print(f" Encoder_E: {param_detail['encoder_e']:,}")
408
+ print(f" Encoder_P: {param_detail['encoder_p']:,}")
409
+ print(f" Predictor: {param_detail['predictor']:,}")
410
+ check("model instantiates", model is not None)
411
+ check("total params > 0", params > 0)
412
+ check("param counts sum correctly",
413
+ param_detail['total'] == params)
414
+
415
+ # ── Test 2: Forward pass shapes ──
416
+ print("\nTest 2: Forward pass shapes")
417
+ N = 64
418
+ env = torch.randn(N, 24, device=device)
419
+ pfam = torch.randn(N, 20, device=device)
420
+ bio = torch.randn(N, 3, device=device)
421
+ bio_valid = torch.ones(N, dtype=torch.bool, device=device)
422
+
423
+ model.train()
424
+ result = model(env, pfam, bio, bio_valid)
425
+ check("z_env shape", result['z_env'].shape == (N, 16))
426
+ check("z_pfam shape", result['z_pfam'].shape == (N, 16))
427
+ check("y_pred shape", result['y_pred'].shape == (N, 3))
428
+ check("total_loss is scalar", result['total_loss'].dim() == 0)
429
+ check("vicreg_loss is scalar", result['vicreg_loss'].dim() == 0)
430
+ check("pred_loss is scalar", result['pred_loss'].dim() == 0)
431
+ check("vicreg_components present",
432
+ all(k in result['vicreg_components']
433
+ for k in ['invariance', 'variance_a', 'variance_b',
434
+ 'covariance_a', 'covariance_b', 'total']))
435
+
436
+ # ── Test 3: Forward without bio targets (VICReg-only mode) ──
437
+ print("\nTest 3: Forward without bio targets (VICReg-only)")
438
+ result_no_bio = model(env, pfam, bio_targets=None)
439
+ check("works without bio targets", result_no_bio['total_loss'].item() > 0)
440
+ check("pred_loss is zero", result_no_bio['pred_loss'].item() == 0.0)
441
+
442
+ # ── Test 4: Forward with partial bio_valid mask ──
443
+ print("\nTest 4: Forward with partial bio_valid mask")
444
+ partial_valid = torch.zeros(N, dtype=torch.bool, device=device)
445
+ partial_valid[:32] = True # Only half valid
446
+ result_partial = model(env, pfam, bio, partial_valid)
447
+ check("works with partial bio_valid", result_partial['total_loss'].item() > 0)
448
+ check("pred_loss computed on valid subset",
449
+ result_partial['pred_loss'].item() >= 0)
450
+
451
+ # Forward with all-invalid bio_valid mask
452
+ all_invalid = torch.zeros(N, dtype=torch.bool, device=device)
453
+ result_novalid = model(env, pfam, bio, all_invalid)
454
+ check("works with all-invalid mask",
455
+ result_novalid['pred_loss'].item() == 0.0)
456
+
457
+ # ── Test 5: Gradient flow ──
458
+ print("\nTest 5: Gradient flow")
459
+ model.zero_grad()
460
+ result = model(env, pfam, bio, bio_valid)
461
+ result['total_loss'].backward()
462
+ all_params = list(model.named_parameters())
463
+ grad_count = sum(1 for _, p in all_params
464
+ if p.grad is not None and p.grad.abs().sum() > 0)
465
+ check(f"all {len(all_params)} param tensors receive gradients",
466
+ grad_count == len(all_params))
467
+ no_nan = all(not torch.isnan(p.grad).any()
468
+ for _, p in all_params if p.grad is not None)
469
+ check("no NaN in any gradient", no_nan)
470
+
471
+ # ── Test 6: Inference mode (env-only) ──
472
+ print("\nTest 6: Inference mode (env-only)")
473
+ model.eval()
474
+ with torch.no_grad():
475
+ y_pred_inf = model.inference(env)
476
+ check("inference returns correct shape", y_pred_inf.shape == (N, 3))
477
+ check("no NaN in inference output", not torch.isnan(y_pred_inf).any())
478
+
479
+ # ── Test 7: Training convergence (50 steps) ──
480
+ print("\nTest 7: Training convergence (50 steps)")
481
+ model.train()
482
+ optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
483
+ losses = []
484
+ for step in range(50):
485
+ optimizer.zero_grad()
486
+ result = model(env, pfam, bio, bio_valid)
487
+ result['total_loss'].backward()
488
+ optimizer.step()
489
+ losses.append(result['total_loss'].item())
490
+ reduction = (losses[0] - losses[-1]) / losses[0] * 100
491
+ print(f" Loss: {losses[0]:.2f} -> {losses[-1]:.2f} ({reduction:.1f}% reduction)")
492
+ check("loss decreases over 50 steps", losses[-1] < losses[0])
493
+ check("no NaN in loss", all(not (l != l) for l in losses))
494
+
495
+ # ── Test 8: Different latent dimensions ──
496
+ print("\nTest 8: Different latent dimensions {16, 32, 64}")
497
+ for ld in [16, 32, 64]:
498
+ m = WorldModel(env_dim=24, pfam_dim=20, latent_dim=ld).to(device)
499
+ m.train()
500
+ r = m(env, pfam, bio, bio_valid)
501
+ check(f"latent_dim={ld}: z_env shape ({N},{ld})",
502
+ r['z_env'].shape == (N, ld))
503
+ check(f"latent_dim={ld}: valid loss",
504
+ r['total_loss'].item() > 0 and not torch.isnan(r['total_loss']))
505
+
506
+ # ── Test 9: Custom VICReg configs ──
507
+ print("\nTest 9: Custom VICReg configurations")
508
+ configs = {
509
+ 'default': dict(lambda_inv=25.0, lambda_var=25.0, lambda_cov=1.0),
510
+ 'high_variance': dict(lambda_inv=10.0, lambda_var=50.0, lambda_cov=1.0),
511
+ 'high_covariance': dict(lambda_inv=25.0, lambda_var=25.0, lambda_cov=10.0),
512
+ }
513
+ for name, cfg in configs.items():
514
+ m = WorldModel(env_dim=24, pfam_dim=20, **cfg).to(device)
515
+ m.train()
516
+ r = m(env, pfam, bio, bio_valid)
517
+ check(f"{name}: valid loss",
518
+ r['total_loss'].item() > 0 and not torch.isnan(r['total_loss']))
519
+
520
+ # ── Test 10: Minimum batch size (N=2) ──
521
+ print("\nTest 10: Minimum batch size (N=2)")
522
+ env_small = torch.randn(2, 24, device=device)
523
+ pfam_small = torch.randn(2, 20, device=device)
524
+ bio_small = torch.randn(2, 3, device=device)
525
+ valid_small = torch.ones(2, dtype=torch.bool, device=device)
526
+ model.train()
527
+ r_small = model(env_small, pfam_small, bio_small, valid_small)
528
+ check("batch size 2 works", not torch.isnan(r_small['total_loss']))
529
+
530
+ # ── Test 11: Standalone encoder methods ──
531
+ print("\nTest 11: Standalone encoder methods")
532
+ model.eval()
533
+ with torch.no_grad():
534
+ ze = model.encode_env(env)
535
+ zp = model.encode_pfam(pfam)
536
+ check("encode_env shape", ze.shape == (N, 16))
537
+ check("encode_pfam shape", zp.shape == (N, 16))
538
+
539
+ # ── Test 12: GPU computation (if available) ──
540
+ print("\nTest 12: GPU computation")
541
+ if torch.cuda.is_available():
542
+ m_gpu = WorldModel(env_dim=24, pfam_dim=20).to('cuda')
543
+ m_gpu.train()
544
+ e_gpu = torch.randn(32, 24, device='cuda')
545
+ p_gpu = torch.randn(32, 20, device='cuda')
546
+ b_gpu = torch.randn(32, 3, device='cuda')
547
+ v_gpu = torch.ones(32, dtype=torch.bool, device='cuda')
548
+ r_gpu = m_gpu(e_gpu, p_gpu, b_gpu, v_gpu)
549
+ r_gpu['total_loss'].backward()
550
+ check("GPU forward + backward succeeded",
551
+ not torch.isnan(r_gpu['total_loss']))
552
+ else:
553
+ print(" SKIP: CUDA not available")
554
+ tests_total += 1
555
+ tests_passed += 1
556
+
557
+ # ── Test 13: Model serialization (save/load) ──
558
+ print("\nTest 13: Model serialization (save/load)")
559
+ import tempfile
560
+ model.eval()
561
+ with torch.no_grad():
562
+ y_before = model.inference(env)
563
+
564
+ checkpoint = {
565
+ 'model_state_dict': model.state_dict(),
566
+ 'config': model.config,
567
+ }
568
+ with tempfile.NamedTemporaryFile(suffix='.pt', delete=False) as f:
569
+ tmp_path = f.name
570
+ torch.save(checkpoint, f)
571
+
572
+ # Load into fresh model
573
+ loaded = torch.load(tmp_path, map_location=device, weights_only=False)
574
+ model2 = WorldModel(**loaded['config']).to(device)
575
+ model2.load_state_dict(loaded['model_state_dict'])
576
+ model2.eval()
577
+ with torch.no_grad():
578
+ y_after = model2.inference(env)
579
+
580
+ max_diff = (y_before - y_after).abs().max().item()
581
+ print(f" Max prediction diff after save/load: {max_diff:.2e}")
582
+ check("save/load produces identical predictions", max_diff < 1e-6)
583
+
584
+ os.unlink(tmp_path)
585
+
586
+ # ── Summary ──
587
+ print(f"\n{'=' * 70}")
588
+ print(f"Results: {tests_passed}/{tests_total} tests passed")
589
+ print(f"{'=' * 70}")
590
+
591
+ return tests_passed == tests_total
592
+
593
+
594
+ if __name__ == '__main__':
595
+ success = self_test()
596
+ sys.exit(0 if success else 1)