yusufcalisir's picture
deploy: Hugging Face space upload
94cb318
Raw History Blame Contribute Delete
14.7 kB
"""Unit tests for GraphSAGE model and graph embedding service.
Tests the core FedGNN components:
1. GraphSAGE model forward pass, embedding dimensions, and gradient flow
2. Node feature extraction from entity dictionaries
3. Model weight serialization/deserialization for federation
4. Graph embedding service: local graph construction, training, similarity search
5. Integration with FL engine: GNN weight aggregation validation
"""
from __future__ import annotations
import numpy as np
import pytest
import torch
from app.application.services.graph_embedding_model import (
NODE_FEATURE_DIM,
GraphSAGELayer,
GraphSAGEModel,
extract_node_features,
)
from app.application.services.graph_embedding_service import GraphEmbeddingService
from app.application.services.graph_engine import GraphEngine
from app.domain.enums import AggregationMethod
# ── Node Feature Extraction ──────────────────────
class TestNodeFeatureExtraction:
"""Tests for extract_node_features()."""
def test_feature_dimension(self) -> None:
"""Feature vector must be exactly NODE_FEATURE_DIM (12) dimensions."""
entity = {
"entity_type": "customer",
"risk_level": "high",
"alert_count": 5,
}
features = extract_node_features(entity, degree=3)
assert features.shape == (NODE_FEATURE_DIM,)
assert features.dtype == np.float32
def test_entity_type_one_hot(self) -> None:
"""Entity type should be one-hot encoded in positions 0-6."""
for etype, expected_idx in [
("customer", 0),
("merchant", 1),
("device", 2),
("card", 3),
]:
features = extract_node_features({"entity_type": etype})
# The expected index should be 1.0, all others 0.0
assert features[expected_idx] == 1.0
for i in range(7):
if i != expected_idx:
assert features[i] == 0.0
def test_risk_level_ordinal(self) -> None:
"""Risk level must be permanently blinded (0.0) and unmasked leakage must fail closed."""
features_masked = extract_node_features({"risk_level": "critical"})
assert features_masked[7] == 0.0, "Risk level ordinal must be masked to 0.0 by default"
with pytest.raises(ValueError, match="Unmasked label leakage in graph features is strictly prohibited"):
extract_node_features({"risk_level": "critical"}, mask_label_leakage=False)
def test_alert_count_normalized(self) -> None:
"""Alert count should be log-normalized and bounded."""
features_zero = extract_node_features({"alert_count": 0})
features_high = extract_node_features({"alert_count": 100})
assert features_zero[8] == 0.0
assert 0.0 < features_high[8] <= 1.5 # log1p(100)/5.0 β‰ˆ 0.92
def test_degree_centrality(self) -> None:
"""Degree should be log-normalized in position 9."""
features_iso = extract_node_features({}, degree=0)
features_hub = extract_node_features({}, degree=50)
assert features_iso[9] == 0.0
assert features_hub[9] > features_iso[9]
def test_empty_entity_dict(self) -> None:
"""Should handle empty dict without crashing."""
features = extract_node_features({})
assert features.shape == (NODE_FEATURE_DIM,)
# Default entity_type is customer (index 0)
assert features[0] == 1.0
# ── GraphSAGE Layer ──────────────────────
class TestGraphSAGELayer:
"""Tests for individual GraphSAGE message-passing layers."""
def test_output_shape(self) -> None:
"""Layer output shape should be (N, out_dim)."""
layer = GraphSAGELayer(in_dim=12, out_dim=64)
features = torch.randn(5, 12)
adj = [[1, 2], [0, 2], [0, 1, 3], [2, 4], [3]]
output = layer(features, adj)
assert output.shape == (5, 64)
def test_isolated_node(self) -> None:
"""Isolated nodes (no neighbors) should still produce embeddings."""
layer = GraphSAGELayer(in_dim=12, out_dim=32)
features = torch.randn(3, 12)
adj = [[], [], []] # All nodes isolated
output = layer(features, adj)
assert output.shape == (3, 32)
# Output should not be all zeros (bias + self-loop)
assert not torch.all(output == 0)
def test_l2_normalization(self) -> None:
"""Output embeddings should be L2-normalized to unit sphere."""
layer = GraphSAGELayer(in_dim=12, out_dim=32)
features = torch.randn(4, 12)
adj = [[1], [0, 2], [1, 3], [2]]
output = layer(features, adj)
norms = torch.norm(output, dim=1)
# Should be approximately 1.0 for non-zero rows
for i in range(4):
if torch.any(output[i] != 0):
assert abs(norms[i].item() - 1.0) < 1e-5
def test_gradient_flow(self) -> None:
"""Gradients should flow through the layer."""
layer = GraphSAGELayer(in_dim=12, out_dim=32)
features = torch.randn(3, 12, requires_grad=True)
adj = [[1, 2], [0], [0]]
output = layer(features, adj)
loss = output.sum()
loss.backward()
assert features.grad is not None
assert not torch.all(features.grad == 0)
# ── GraphSAGE Model ──────────────────────
class TestGraphSAGEModel:
"""Tests for the full GraphSAGE model."""
def test_embedding_dimension(self) -> None:
"""Model should produce embeddings of the configured dimension."""
model = GraphSAGEModel(input_dim=12, hidden_dim=64, embedding_dim=32, num_layers=2)
features = torch.randn(5, 12)
adj = [[1, 2], [0, 3], [0, 4], [1], [2]]
embeddings = model.get_embeddings(features, adj)
assert embeddings.shape == (5, 32)
def test_forward_returns_embeddings_and_predictions(self) -> None:
"""Forward pass should return both embeddings and fraud predictions."""
model = GraphSAGEModel(input_dim=12, hidden_dim=64, embedding_dim=32, num_layers=2)
features = torch.randn(4, 12)
adj = [[1], [0, 2], [1, 3], [2]]
embeddings, predictions = model(features, adj)
assert embeddings.shape == (4, 32)
assert predictions.shape == (4,)
# Predictions should be in [0, 1] (sigmoid output)
assert torch.all(predictions >= 0)
assert torch.all(predictions <= 1)
def test_model_weights_serialization_roundtrip(self) -> None:
"""Weights should survive serialize β†’ deserialize without loss."""
model = GraphSAGEModel(input_dim=12, hidden_dim=64, embedding_dim=32, num_layers=2)
# Set known weights
for param in model.parameters():
param.data = torch.randn_like(param)
# Serialize
weights = model.to_model_weights(include_classifier=True)
assert len(weights.flat_weights) > 0
assert len(weights.layer_shapes) > 0
# Create new model and load weights
model2 = GraphSAGEModel(input_dim=12, hidden_dim=64, embedding_dim=32, num_layers=2)
model2.load_model_weights(weights, include_classifier=True)
# Compare parameters
for p1, p2 in zip(model.parameters(), model2.parameters()):
assert torch.allclose(p1.data, p2.data, atol=1e-6)
def test_model_weights_parameter_count(self) -> None:
"""Serialized weight count should match model parameter count."""
model = GraphSAGEModel(input_dim=12, hidden_dim=128, embedding_dim=64, num_layers=2)
weights = model.to_model_weights(include_classifier=True)
expected_params = sum(p.numel() for p in model.parameters())
assert weights.num_parameters == expected_params
# ── Model Weight Aggregation ──────────────────────
class TestGNNAggregation:
"""Tests for GNN weight aggregation via fl_engine."""
def test_fedavg_gnn_weights(self) -> None:
"""FedAvg should correctly average GNN model weights."""
from app.application.services.fl_engine import FederatedLearningEngine
# Create two models with different weights
model_a = GraphSAGEModel(input_dim=12, hidden_dim=32, embedding_dim=16, num_layers=1)
model_b = GraphSAGEModel(input_dim=12, hidden_dim=32, embedding_dim=16, num_layers=1)
for p in model_a.parameters():
p.data = torch.ones_like(p) * 2.0
for p in model_b.parameters():
p.data = torch.ones_like(p) * 4.0
weights_a = model_a.to_model_weights()
weights_b = model_b.to_model_weights()
# Create a mock engine (doesn't need real services for aggregation)
engine = FederatedLearningEngine.__new__(FederatedLearningEngine)
aggregated = engine.aggregate_parameters(
[weights_a, weights_b],
[100, 100],
method=AggregationMethod.FED_AVG,
)
# Average of 2.0 and 4.0 = 3.0
for w in aggregated.flat_weights:
assert abs(w - 3.0) < 1e-6
def test_gnn_layer_shape_validation(self) -> None:
"""aggregate_graph_parameters should reject mismatched layer shapes."""
from app.application.services.fl_engine import FederatedLearningEngine
# With num_layers=2, hidden_dim affects intermediate layer shapes
model_a = GraphSAGEModel(input_dim=12, hidden_dim=32, embedding_dim=16, num_layers=2)
model_b = GraphSAGEModel(input_dim=12, hidden_dim=64, embedding_dim=16, num_layers=2)
weights_a = model_a.to_model_weights()
weights_b = model_b.to_model_weights()
engine = FederatedLearningEngine.__new__(FederatedLearningEngine)
with pytest.raises(ValueError, match="GNN layer shape mismatch"):
engine.aggregate_graph_parameters(
[weights_a, weights_b],
[100, 100],
)
# ── Graph Embedding Service ──────────────────────
class TestGraphEmbeddingService:
"""Tests for the graph embedding orchestration service."""
def test_empty_graph_training(self) -> None:
"""Training on an empty graph should return zero loss without crashing."""
service = GraphEmbeddingService(graph_engine=GraphEngine())
weights, metrics = service.train_local_gnn(bank_id="bank_nonexistent")
assert metrics["loss"] == 0.0
assert metrics["num_nodes"] == 0
def test_embedding_stats_empty(self) -> None:
"""Stats should return zeros when no embeddings exist."""
service = GraphEmbeddingService()
stats = service.get_embedding_stats()
assert stats["num_embedded_nodes"] == 0
def test_find_similar_no_embeddings(self) -> None:
"""Similarity search should return empty when no embeddings cached."""
service = GraphEmbeddingService()
results = service.find_similar_entities("nonexistent")
assert results == []
def test_get_all_embeddings_serializable(self) -> None:
"""get_all_embeddings should return JSON-serializable dict."""
service = GraphEmbeddingService()
# Manually inject a cached embedding
service._embeddings["test_node"] = np.array([0.1, 0.2, 0.3], dtype=np.float32)
result = service.get_all_embeddings()
assert "test_node" in result
assert isinstance(result["test_node"], list)
assert len(result["test_node"]) == 3
def test_cosine_similarity_search(self) -> None:
"""Cosine similarity search should find similar embeddings."""
service = GraphEmbeddingService()
# Create embeddings: A and B are similar, C is different
service._embeddings["node_a"] = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32)
service._embeddings["node_b"] = np.array([0.9, 0.1, 0.0, 0.0], dtype=np.float32)
service._embeddings["node_c"] = np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float32)
results = service.find_similar_entities("node_a", top_k=5, threshold=0.5)
# node_b should be found (high similarity to node_a)
entity_ids = [r["entity_id"] for r in results]
assert "node_b" in entity_ids
# node_c should NOT be found (orthogonal to node_a)
assert "node_c" not in entity_ids
class TestGraphSAGETensorEdgeIndex:
"""Tests evaluating PyG-style edge_index tensors and masked semi-supervised loss."""
def test_graphsage_layer_forward_with_edge_index(self) -> None:
"""Verify GraphSAGELayer forward pass with tensor edge_index produces valid embeddings."""
torch.manual_seed(42)
layer = GraphSAGELayer(in_dim=16, out_dim=8)
node_features = torch.randn(10, 16)
edge_index = torch.tensor([[0, 1, 2, 3, 4], [1, 2, 3, 4, 0]], dtype=torch.long)
out = layer(node_features, edge_index=edge_index)
assert out.shape == (10, 8)
# Verify L2 normalization on active embeddings
for i in range(10):
if torch.any(out[i] != 0):
norm = torch.norm(out[i], p=2)
assert abs(norm.item() - 1.0) < 1e-5
def test_graphsage_model_edge_index_and_166_features(self) -> None:
"""Verify GraphSAGEModel handles 166-dim Elliptic features with edge_index."""
model = GraphSAGEModel(input_dim=166, hidden_dim=32, embedding_dim=16, num_layers=2)
node_features = torch.randn(20, 166)
edge_index = torch.tensor([[0, 1, 2, 3], [1, 2, 3, 0]], dtype=torch.long)
embeddings, predictions = model(node_features, edge_index=edge_index)
assert embeddings.shape == (20, 16)
assert predictions.shape == (20,)
assert (predictions >= 0.0).all() and (predictions <= 1.0).all()
def test_graphsage_model_compute_loss_with_mask(self) -> None:
"""Verify masked loss computation strictly isolates labeled nodes."""
model = GraphSAGEModel(input_dim=16, hidden_dim=16, embedding_dim=8)
predictions = torch.tensor([0.9, 0.1, 0.8, 0.2], requires_grad=True)
targets = torch.tensor([1, 0, 0, 1])
mask = torch.tensor([True, True, False, False])
loss = model.compute_loss(predictions, targets, mask=mask)
assert loss.item() > 0.0
# Gradient should flow to masked items and not to unmasked items
loss.backward()
assert predictions.grad is not None
assert predictions.grad[0] != 0.0
assert predictions.grad[1] != 0.0
assert predictions.grad[2] == 0.0
assert predictions.grad[3] == 0.0