Text-to-Image
Diffusers
Safetensors
StableDiffusionPipeline
stable-diffusion
medical-imaging
fundus
compdiff
Instructions to use mahmoudibra98/compdiff-fundus with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use mahmoudibra98/compdiff-fundus with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("mahmoudibra98/compdiff-fundus", dtype=torch.bfloat16, device_map="cuda") prompt = "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k" image = pipe(prompt).images[0] - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- Draw Things
- DiffusionBee
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() |