File size: 26,884 Bytes
4d6c0b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
"""
Hierarchical Conditioner Network V8: Auxiliary Loss on Output Token

Key insight from V1 analysis:
- HCN token improves FID by 18 points (96 β†’ 78)
- HCN embeddings are perfectly discriminative (aux_loss β†’ 0)
- BUT demographics don't transfer to generated images (50% accuracy)

The problem: aux_loss was applied to `mu`, but `proj_ctx` can destroy 
demographic information before the UNet sees it.

V8 Fix: Apply auxiliary classification on the OUTPUT TOKEN, not on mu.
This forces proj_ctx to preserve demographic information.

    h_child β†’ mu_head β†’ mu β†’ proj_ctx β†’ token β†’ UNet
                                          ↓
                                  aux_classifiers βœ“ (NOW supervised here)

Authors: RoentGen V8 Team
"""

import torch
import torch.nn as nn
import torch.nn.functional as F
from typing import Tuple, Optional, Dict
import json
import os


class MLP(nn.Module):
    """
    Multi-layer perceptron with LayerNorm and SiLU activation.

    Args:
        d_in: Input dimension
        d_hidden: Hidden layer dimension
        d_out: Output dimension
        dropout: Dropout probability (default: 0.1)
    """
    def __init__(self, d_in: int, d_hidden: int, d_out: int, dropout: float = 0.1):
        super().__init__()
        self.net = nn.Sequential(
            nn.LayerNorm(d_in),
            nn.Linear(d_in, d_hidden),
            nn.SiLU(),
            nn.Dropout(dropout),
            nn.Linear(d_hidden, d_out),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.net(x)


class HierarchicalConditionerV8(nn.Module):
    """
    V8: Hierarchical Conditioning Network with Auxiliary Loss on Output Token
    
    The key change from V1: auxiliary classifiers are applied to the OUTPUT
    TOKEN (after proj_ctx) rather than to mu. This forces the projection
    to preserve demographic information.
    
    Architecture:
        Grandparents (single attributes) β†’ Parents (pairwise) β†’ Child (triple)
        β†’ mu/logsigma β†’ sample z β†’ proj_ctx β†’ TOKEN β†’ aux_classifiers
                                                ↓
                                              UNet

    Args:
        num_age_bins: Number of age categories
        num_sex: Number of sex categories (typically 2: M/F)
        num_race: Number of race/ethnicity categories
        d_node: Hidden dimension for embeddings (default: 256)
        d_ctx: Output dimension matching UNet cross_attention_dim (default: 1024)
        dropout: Dropout probability (default: 0.1)
        use_uncertainty: Whether to output mu/logsigma for variational sampling
        use_aux_loss: Whether to include auxiliary classifiers (on token)
        aux_hidden_dim: Hidden dimension for auxiliary classifiers (default: 512)
        encode_age: Whether to include age in the hierarchy (default: True)
                     If False, only sex Γ— race composition is used

    Input:
        age_idx: [B] Long tensor of age bin indices (0 to num_age_bins-1), optional if encode_age=False
        sex_idx: [B] Long tensor of sex indices (0 to num_sex-1)
        race_idx: [B] Long tensor of race indices (0 to num_race-1)

    Output:
        ctx: [B, 1, d_ctx] - Demographic context token to concatenate with text
        mu: [B, d_node] - Mean of variational distribution
        logsigma: [B, d_node] - Log std of variational distribution
        aux_logits: Dict with 'age' (if encode_age), 'sex', 'race' logits (from TOKEN, not mu)
    """
    
    def __init__(
        self,
        num_age_bins: int,
        num_sex: int,
        num_race: int,
        d_node: int = 256,
        d_ctx: int = 1024,
        dropout: float = 0.1,
        use_uncertainty: bool = True,
        use_aux_loss: bool = True,  # Default True for V8
        aux_hidden_dim: int = 512,  # V8: Hidden dim for token classifiers
        encode_age: bool = True,  # V10: Optionally exclude age
    ):
        super().__init__()

        # Store config for saving/loading
        self.config = {
            'num_age_bins': num_age_bins,
            'num_sex': num_sex,
            'num_race': num_race,
            'd_node': d_node,
            'd_ctx': d_ctx,
            'dropout': dropout,
            'use_uncertainty': use_uncertainty,
            'use_aux_loss': use_aux_loss,
            'aux_hidden_dim': aux_hidden_dim,
            'encode_age': encode_age,
        }

        self.num_age = num_age_bins
        self.num_sex = num_sex
        self.num_race = num_race
        self.d_node = d_node
        self.d_ctx = d_ctx
        self.use_uncertainty = use_uncertainty
        self.use_aux_loss = use_aux_loss
        self.encode_age = encode_age

        # === Grandparent embeddings (single attributes) ===
        self.emb_sex = nn.Embedding(num_sex, d_node)
        self.emb_race = nn.Embedding(num_race, d_node)
        
        if encode_age:
            self.emb_age = nn.Embedding(num_age_bins, d_node)
        else:
            self.emb_age = None

        # === Parent composers (pairwise compositions) ===
        if encode_age:
            # Full hierarchy: age Γ— sex Γ— race
            self.compose_age_sex = MLP(
                d_in=2 * d_node,
                d_hidden=2 * d_node,
                d_out=d_node,
                dropout=dropout
            )
            self.compose_age_race = MLP(
                d_in=2 * d_node,
                d_hidden=2 * d_node,
                d_out=d_node,
                dropout=dropout
            )
            self.compose_sex_race = MLP(
                d_in=2 * d_node,
                d_hidden=2 * d_node,
                d_out=d_node,
                dropout=dropout
            )
            # === Child composer (triple composition from all parents) ===
            self.compose_all = MLP(
                d_in=3 * d_node,
                d_hidden=2 * d_node,
                d_out=d_node,
                dropout=dropout
            )
        else:
            # Simplified: sex Γ— race only
            self.compose_age_sex = None
            self.compose_age_race = None
            self.compose_sex_race = MLP(
                d_in=2 * d_node,
                d_hidden=2 * d_node,
                d_out=d_node,
                dropout=dropout
            )
            self.compose_all = None

        # === Uncertainty heads (for rare group detection) ===
        if use_uncertainty:
            self.mu_head = nn.Linear(d_node, d_node)
            self.logsigma_head = nn.Linear(d_node, d_node)

        # === Project to UNet cross-attention dimension ===
        self.proj_ctx = nn.Sequential(
            nn.LayerNorm(d_node),
            nn.Linear(d_node, d_ctx),
        )

        # === V8: Auxiliary classifiers on OUTPUT TOKEN (d_ctx), not mu (d_node) ===
        # This is the key change - forces proj_ctx to preserve demographics
        if use_aux_loss:
            # More expressive classifiers since we're working from d_ctx
            if encode_age:
                self.age_classifier = nn.Sequential(
                    nn.LayerNorm(d_ctx),
                    nn.Linear(d_ctx, aux_hidden_dim),
                    nn.SiLU(),
                    nn.Dropout(dropout),
                    nn.Linear(aux_hidden_dim, num_age_bins),
                )
            else:
                self.age_classifier = None
            self.sex_classifier = nn.Sequential(
                nn.LayerNorm(d_ctx),
                nn.Linear(d_ctx, aux_hidden_dim),
                nn.SiLU(),
                nn.Dropout(dropout),
                nn.Linear(aux_hidden_dim, num_sex),
            )
            self.race_classifier = nn.Sequential(
                nn.LayerNorm(d_ctx),
                nn.Linear(d_ctx, aux_hidden_dim),
                nn.SiLU(),
                nn.Dropout(dropout),
                nn.Linear(aux_hidden_dim, num_race),
            )
        else:
            self.age_classifier = None
            self.sex_classifier = None
            self.race_classifier = None

        self._init_weights()

    def _init_weights(self):
        """Initialize embeddings with small normal distribution."""
        embeddings = [self.emb_sex, self.emb_race]
        if self.emb_age is not None:
            embeddings.append(self.emb_age)
        for emb in embeddings:
            nn.init.normal_(emb.weight, mean=0.0, std=0.02)

        # Initialize uncertainty heads conservatively
        if self.use_uncertainty:
            nn.init.normal_(self.mu_head.weight, mean=0.0, std=0.01)
            nn.init.zeros_(self.mu_head.bias)
            nn.init.normal_(self.logsigma_head.weight, mean=0.0, std=0.01)
            nn.init.constant_(self.logsigma_head.bias, -1.0)  # Start with low variance

    def forward(
        self,
        sex_idx: torch.Tensor,
        race_idx: torch.Tensor,
        age_idx: Optional[torch.Tensor] = None,
    ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, Optional[Dict[str, torch.Tensor]], Optional[torch.Tensor]]:
        """
        Forward pass through hierarchical conditioning network.

        Args:
            sex_idx: [B] Long tensor of sex indices
            race_idx: [B] Long tensor of race indices
            age_idx: [B] Long tensor of age bin indices (optional if encode_age=False)

        Returns:
            ctx: [B, 1, d_ctx] - Demographic context to concatenate with text
            mu: [B, d_node] - Mean of variational distribution
            logsigma: [B, d_node] - Log std of variational distribution
            aux_logits: Dict with 'age' (if encode_age), 'sex', 'race' logits (FROM TOKEN)
            time_emb: None - V8 does not support timestep injection
        """
        # === Level 1: Grandparent embeddings (single attributes) ===
        e_sex = self.emb_sex(sex_idx)    # [B, d_node]
        e_race = self.emb_race(race_idx) # [B, d_node]
        
        if self.encode_age and age_idx is not None:
            e_age = self.emb_age(age_idx)    # [B, d_node]
            # === Level 2: Parent compositions (pairwise) ===
            h_age_sex = self.compose_age_sex(torch.cat([e_age, e_sex], dim=-1))
            h_age_race = self.compose_age_race(torch.cat([e_age, e_race], dim=-1))
            h_sex_race = self.compose_sex_race(torch.cat([e_sex, e_race], dim=-1))
            # === Level 3: Child composition (from all parents) ===
            h_child = self.compose_all(
                torch.cat([h_age_sex, h_age_race, h_sex_race], dim=-1)
            )
        else:
            # === Simplified: sex Γ— race only ===
            h_child = self.compose_sex_race(torch.cat([e_sex, e_race], dim=-1))

        # === Uncertainty quantification (variational) ===
        if self.use_uncertainty:
            mu = self.mu_head(h_child)
            logsigma = torch.clamp(
                self.logsigma_head(h_child),
                min=-5.0,  # Minimum variance (stable training)
                max=1.0    # Maximum variance (prevent explosion)
            )

            # Sample during training (reparameterization trick)
            # Use mean during inference (deterministic)
            if self.training:
                z = mu + torch.exp(logsigma) * torch.randn_like(mu)
            else:
                z = mu
        else:
            mu = h_child
            logsigma = torch.zeros_like(h_child)
            z = h_child

        # === Project to context token ===
        ctx = self.proj_ctx(z).unsqueeze(1)  # [B, 1, d_ctx]

        # === V8: Auxiliary logits FROM TOKEN (not from mu!) ===
        # This is the critical change - forces proj_ctx to preserve demographics
        aux_logits = None
        if self.use_aux_loss:
            token = ctx.squeeze(1)  # [B, d_ctx]
            aux_logits = {
                "sex": self.sex_classifier(token),
                "race": self.race_classifier(token),
            }
            if self.encode_age and self.age_classifier is not None:
                aux_logits["age"] = self.age_classifier(token)

        # V8 does not support timestep injection, return None for compatibility
        time_emb = None

        return ctx, mu, logsigma, aux_logits, time_emb

    def compute_compositional_loss(
        self,
        sex_idx: torch.Tensor,
        race_idx: torch.Tensor,
        age_idx: Optional[torch.Tensor] = None,
    ) -> torch.Tensor:
        """
        Compute compositional consistency loss.

        Enforces that the hierarchical composition is consistent with
        simple additive composition of grandparent embeddings.
        """
        # Get grandparent embeddings
        e_sex = self.emb_sex(sex_idx)
        e_race = self.emb_race(race_idx)
        
        if self.encode_age and age_idx is not None:
            e_age = self.emb_age(age_idx)
            # Hierarchical composition
            h_age_sex = self.compose_age_sex(torch.cat([e_age, e_sex], -1))
            h_age_race = self.compose_age_race(torch.cat([e_age, e_race], -1))
            h_sex_race = self.compose_sex_race(torch.cat([e_sex, e_race], -1))
            h_child = self.compose_all(torch.cat([h_age_sex, h_age_race, h_sex_race], -1))
            # Simple additive baseline
            h_additive = e_age + e_sex + e_race
        else:
            # Simplified: sex Γ— race only
            h_child = self.compose_sex_race(torch.cat([e_sex, e_race], -1))
            # Simple additive baseline
            h_additive = e_sex + e_race

        # Cosine similarity loss
        cos_sim = F.cosine_similarity(h_child, h_additive, dim=-1)
        loss_comp = (1 - cos_sim).mean()

        return loss_comp

    def get_uncertainty(
        self,
        sex_idx: torch.Tensor,
        race_idx: torch.Tensor,
        age_idx: Optional[torch.Tensor] = None,
    ) -> torch.Tensor:
        """
        Get uncertainty (sigma) for given demographic groups.
        Useful for detecting which groups the model is uncertain about.
        """
        _, _, logsigma, _ = self.forward(sex_idx, race_idx, age_idx)
        sigma = torch.exp(logsigma).mean(dim=-1)
        return sigma

    def save_pretrained(self, save_dir: str):
        """Save HCN model and config."""
        os.makedirs(save_dir, exist_ok=True)

        # Save config
        config_path = os.path.join(save_dir, "config.json")
        with open(config_path, "w") as f:
            json.dump(self.config, f, indent=2)

        # Save weights
        weights_path = os.path.join(save_dir, "pytorch_model.bin")
        torch.save(self.state_dict(), weights_path)

        print(f"HCN V8 saved to {save_dir}")

    @classmethod
    def from_pretrained(cls, save_dir: str, device: str = "cpu"):
        """Load HCN model from saved checkpoint."""
        # Load config
        config_path = os.path.join(save_dir, "config.json")
        with open(config_path, "r") as f:
            config = json.load(f)

        # Create model
        model = cls(**config)

        # Load weights
        weights_path = os.path.join(save_dir, "pytorch_model.bin")
        state_dict = torch.load(weights_path, map_location=device)
        model.load_state_dict(state_dict)

        model.to(device)
        model.eval()

        print(f"HCN V8 loaded from {save_dir}")
        return model


def compute_aux_loss(
    aux_logits: Dict[str, torch.Tensor],
    sex_idx: torch.Tensor,
    race_idx: torch.Tensor,
    age_idx: Optional[torch.Tensor] = None,
    age_weight: float = 1.0,
    sex_weight: float = 1.0,
    race_weight: float = 1.0,
) -> Tuple[torch.Tensor, Dict[str, float]]:
    """
    Compute auxiliary classification loss from token logits.
    
    Args:
        aux_logits: Dict with 'age' (optional), 'sex', 'race' logits
        sex_idx, race_idx: Ground truth labels
        age_idx: Ground truth age labels (optional if age not encoded)
        *_weight: Per-attribute loss weights
        
    Returns:
        total_loss: Weighted sum of CE losses
        metrics: Dict with individual losses and accuracies
    """
    losses = []
    weights = []
    metrics = {}
    
    sex_ce = F.cross_entropy(aux_logits["sex"], sex_idx)
    race_ce = F.cross_entropy(aux_logits["race"], race_idx)
    losses.append(sex_ce)
    weights.append(sex_weight)
    losses.append(race_ce)
    weights.append(race_weight)
    
    # Compute accuracies for logging
    with torch.no_grad():
        sex_acc = (aux_logits["sex"].argmax(-1) == sex_idx).float().mean()
        race_acc = (aux_logits["race"].argmax(-1) == race_idx).float().mean()
    
    metrics["aux_loss_sex"] = sex_ce.item()
    metrics["aux_loss_race"] = race_ce.item()
    metrics["aux_acc_sex"] = sex_acc.item()
    metrics["aux_acc_race"] = race_acc.item()
    
    # Age loss (if age is encoded)
    if "age" in aux_logits and age_idx is not None:
        age_ce = F.cross_entropy(aux_logits["age"], age_idx)
        losses.append(age_ce)
        weights.append(age_weight)
        with torch.no_grad():
            age_acc = (aux_logits["age"].argmax(-1) == age_idx).float().mean()
        metrics["aux_loss_age"] = age_ce.item()
        metrics["aux_acc_age"] = age_acc.item()
    
    # Weighted average
    total_loss = sum(w * l for w, l in zip(weights, losses)) / sum(weights)
    
    return total_loss, metrics


def load_hcn_v8(args, logger):
    """
    Load and initialize HCN V8.
    
    Args:
        args: Training arguments/config
        logger: Logger instance
        
    Returns:
        hcn: HierarchicalConditionerV8 instance or None
    """
    if not getattr(args, 'use_hcn', False):
        logger.info("HCN disabled (use_hcn=False)")
        return None
    
    logger.info("=" * 60)
    logger.info("Initializing HCN V8 (Auxiliary Loss on Token)")
    logger.info("=" * 60)
    
    # Determine aux_loss setting
    use_aux_loss = getattr(args, 'hcn_aux_weight', 0.0) > 0.0
    aux_hidden_dim = getattr(args, 'hcn_aux_hidden_dim', 512)
    encode_age = getattr(args, 'hcn_encode_age', True)  # V10: Optionally exclude age
    
    hcn = HierarchicalConditionerV8(
        num_age_bins=getattr(args, 'hcn_num_age_bins', 5),
        num_sex=getattr(args, 'hcn_num_sex', 2),
        num_race=getattr(args, 'hcn_num_race', 4),
        d_node=getattr(args, 'hcn_d_node', 256),
        d_ctx=getattr(args, 'hcn_d_ctx', 1024),
        dropout=getattr(args, 'hcn_dropout', 0.1),
        use_uncertainty=getattr(args, 'hcn_use_uncertainty', True),
        use_aux_loss=use_aux_loss,
        aux_hidden_dim=aux_hidden_dim,
        encode_age=encode_age,
    )
    
    num_params = sum(p.numel() for p in hcn.parameters())
    logger.info(f"  Total parameters: {num_params:,}")
    logger.info(f"  Encode age: {encode_age}")
    if encode_age:
        logger.info(f"  Age bins: {hcn.num_age}")
    logger.info(f"  Sex categories: {hcn.num_sex}")
    logger.info(f"  Race categories: {hcn.num_race}")
    logger.info(f"  Node dimension: {hcn.d_node}")
    logger.info(f"  Context dimension: {hcn.d_ctx}")
    logger.info(f"  Uncertainty: {hcn.use_uncertainty}")
    logger.info(f"  Auxiliary loss (on token): {use_aux_loss}")
    if use_aux_loss:
        logger.info(f"  Auxiliary hidden dimension: {aux_hidden_dim}")
    logger.info("=" * 60)
    
    return hcn


# =============================================================================
# Training loop integration example
# =============================================================================

def train_step_v8(
    hcn: HierarchicalConditionerV8,
    batch: Dict[str, torch.Tensor],
    encoder_hidden_states: torch.Tensor,
    args,
    global_step: int,
) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, Optional[torch.Tensor], Dict]:
    """
    V8 training step - get HCN token and compute losses.
    
    Returns:
        encoder_hidden_states: Modified with HCN token concatenated
        kl_loss: KL divergence loss
        comp_loss: Compositional consistency loss  
        aux_loss: Auxiliary classification loss (on token)
        logs: Dict of metrics to log
    """
    # Get HCN outputs
    age_idx = batch.get("age_idx") if hcn.encode_age else None
    hcn_ctx, mu, logsigma, aux_logits, _ = hcn(
        sex_idx=batch["sex_idx"],
        race_idx=batch["race_idx"],
        age_idx=age_idx,
    )
    
    # Concatenate HCN token to text embeddings
    encoder_hidden_states = torch.cat(
        [encoder_hidden_states, hcn_ctx], dim=1
    )  # [B, 78, d_ctx]
    
    # Compute KL loss
    kl_loss = -0.5 * torch.sum(
        1 + 2 * logsigma - mu ** 2 - torch.exp(2 * logsigma),
        dim=-1
    ).mean()
    
    # Compute compositional loss (need unwrapped model for custom methods)
    comp_loss = hcn.compute_compositional_loss(
        sex_idx=batch["sex_idx"],
        race_idx=batch["race_idx"],
        age_idx=age_idx,
    )
    
    # Compute auxiliary loss (V8: on token!)
    aux_loss = None
    logs = {
        "hcn_ctx_norm": hcn_ctx.norm(dim=-1).mean().item(),
        "kl_loss": kl_loss.item(),
        "comp_loss": comp_loss.item(),
    }
    
    if aux_logits is not None:
        aux_loss, aux_metrics = compute_aux_loss(
            aux_logits,
            sex_idx=batch["sex_idx"],
            race_idx=batch["race_idx"],
            age_idx=age_idx,
        )
        logs.update(aux_metrics)
        logs["aux_loss"] = aux_loss.item()
    
    return encoder_hidden_states, kl_loss, comp_loss, aux_loss, logs


# =============================================================================
# Tests
# =============================================================================

def test_hcn_v8():
    """Test HCN V8 module."""
    print("Testing HCN V8...")
    print("=" * 60)

    # Create model
    hcn = HierarchicalConditionerV8(
        num_age_bins=5,
        num_sex=2,
        num_race=4,
        d_node=256,
        d_ctx=1024,
        use_aux_loss=True,
        aux_hidden_dim=512,
    )

    batch_size = 8
    age = torch.randint(0, 5, (batch_size,))
    sex = torch.randint(0, 2, (batch_size,))
    race = torch.randint(0, 4, (batch_size,))

    # Test forward pass with age
    hcn.train()
    ctx, mu, logsigma, aux_logits, time_emb = hcn(sex_idx=sex, race_idx=race, age_idx=age)

    assert ctx.shape == (batch_size, 1, 1024), f"Expected (8, 1, 1024), got {ctx.shape}"
    assert mu.shape == (batch_size, 256), f"Expected (8, 256), got {mu.shape}"
    assert logsigma.shape == (batch_size, 256), f"Expected (8, 256), got {logsigma.shape}"
    assert aux_logits is not None, "aux_logits should not be None"
    assert time_emb is None, "time_emb should be None for V8"
    print(f"βœ“ Forward pass (with age): ctx shape = {ctx.shape}")

    # Test that aux_logits have correct shapes
    assert aux_logits["age"].shape == (batch_size, 5), f"Age logits wrong shape"
    assert aux_logits["sex"].shape == (batch_size, 2), f"Sex logits wrong shape"
    assert aux_logits["race"].shape == (batch_size, 4), f"Race logits wrong shape"
    print(f"βœ“ Aux logits shapes correct")

    # Test auxiliary loss computation
    aux_loss, metrics = compute_aux_loss(aux_logits, sex_idx=sex, race_idx=race, age_idx=age)
    assert aux_loss.ndim == 0, "Aux loss should be scalar"
    print(f"βœ“ Aux loss: {aux_loss.item():.4f}")
    print(f"  Age acc: {metrics['aux_acc_age']:.2%}")
    print(f"  Sex acc: {metrics['aux_acc_sex']:.2%}")
    print(f"  Race acc: {metrics['aux_acc_race']:.2%}")

    # Test compositional loss
    comp_loss = hcn.compute_compositional_loss(sex_idx=sex, race_idx=race, age_idx=age)
    assert comp_loss.ndim == 0, "Compositional loss should be scalar"
    print(f"βœ“ Compositional loss: {comp_loss.item():.4f}")

    # Test uncertainty
    sigma = hcn.get_uncertainty(sex_idx=sex, race_idx=race, age_idx=age)
    assert sigma.shape == (batch_size,), f"Expected ({batch_size},), got {sigma.shape}"
    print(f"βœ“ Uncertainty: mean sigma = {sigma.mean().item():.4f}")

    # Test gradient flow through aux classifiers to proj_ctx
    print("\n--- Testing gradient flow ---")
    hcn.zero_grad()
    ctx, _, _, aux_logits, _ = hcn(sex_idx=sex, race_idx=race, age_idx=age)
    aux_loss, _ = compute_aux_loss(aux_logits, sex_idx=sex, race_idx=race, age_idx=age)
    aux_loss.backward()
    
    # Check that proj_ctx gets gradients from aux_loss
    proj_ctx_grad = hcn.proj_ctx[1].weight.grad
    assert proj_ctx_grad is not None, "proj_ctx should have gradients!"
    assert proj_ctx_grad.abs().sum() > 0, "proj_ctx gradients should be non-zero!"
    print(f"βœ“ proj_ctx gradient norm: {proj_ctx_grad.norm().item():.6f}")
    print("  This confirms aux_loss flows back through proj_ctx!")

    # Test save/load
    print("\n--- Testing save/load ---")
    import tempfile
    import shutil
    temp_dir = tempfile.mkdtemp()
    try:
        hcn.save_pretrained(temp_dir)
        hcn_loaded = HierarchicalConditionerV8.from_pretrained(temp_dir)
        ctx_loaded, _, _, aux_logits_loaded, _ = hcn_loaded(sex_idx=sex, race_idx=race, age_idx=age)
        assert aux_logits_loaded is not None
        print(f"βœ“ Save/load successful")
    finally:
        shutil.rmtree(temp_dir)
    
    # Test without age encoding
    print("\n--- Testing without age encoding ---")
    hcn_no_age = HierarchicalConditionerV8(
        num_age_bins=5,
        num_sex=2,
        num_race=4,
        d_node=256,
        d_ctx=1024,
        use_aux_loss=True,
        aux_hidden_dim=512,
        encode_age=False,
    )
    hcn_no_age.train()
    ctx_no_age, mu_no_age, logsigma_no_age, aux_logits_no_age, _ = hcn_no_age(
        sex_idx=sex, race_idx=race, age_idx=None
    )
    assert ctx_no_age.shape == (batch_size, 1, 1024), f"Expected (8, 1, 1024), got {ctx_no_age.shape}"
    assert "age" not in aux_logits_no_age, "Age should not be in aux_logits when encode_age=False"
    assert "sex" in aux_logits_no_age, "Sex should be in aux_logits"
    assert "race" in aux_logits_no_age, "Race should be in aux_logits"
    print(f"βœ“ Forward pass (without age): ctx shape = {ctx_no_age.shape}")
    
    # Test auxiliary loss without age
    aux_loss_no_age, metrics_no_age = compute_aux_loss(
        aux_logits_no_age, sex_idx=sex, race_idx=race, age_idx=None
    )
    assert aux_loss_no_age.ndim == 0, "Aux loss should be scalar"
    assert "aux_acc_age" not in metrics_no_age, "Age metrics should not exist"
    print(f"βœ“ Aux loss (no age): {aux_loss_no_age.item():.4f}")
    print(f"  Sex acc: {metrics_no_age['aux_acc_sex']:.2%}")
    print(f"  Race acc: {metrics_no_age['aux_acc_race']:.2%}")

    # Summary
    print("\n" + "=" * 60)
    print("V8 KEY DIFFERENCE FROM V1:")
    print("  V1: aux_classifiers(mu)     - proj_ctx can discard demographics")
    print("  V8: aux_classifiers(token)  - proj_ctx MUST preserve demographics")
    print("=" * 60)
    print(f"βœ“ All V8 tests passed!")
    print(f"βœ“ Total parameters: {sum(p.numel() for p in hcn.parameters()):,}")


if __name__ == "__main__":
    test_hcn_v8()