145 lines
4.7 KiB
Python
145 lines
4.7 KiB
Python
"""
|
|
©AngelaMos | 2026
|
|
test_autoencoder.py
|
|
|
|
Tests the ThreatAutoencoder PyTorch architecture for shape
|
|
correctness, output range, reconstruction error, and
|
|
training behavior
|
|
|
|
Validates output shape matches input (batch, 35), encoder
|
|
bottleneck compresses to 6 dimensions, single-sample
|
|
forward pass succeeds in eval mode, decoder output is
|
|
unbounded (matching RobustScaler range), reconstruction
|
|
error returns one positive scalar per sample, trained model
|
|
reconstructs normal data better than anomalies after 50
|
|
epochs, eval mode produces deterministic output (dropout
|
|
off), and variable batch sizes (1, 8, 32, 128) are handled
|
|
|
|
Connects to:
|
|
ml/autoencoder - ThreatAutoencoder
|
|
"""
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from ml.autoencoder import ThreatAutoencoder
|
|
|
|
|
|
class TestAutoencoderArchitecture:
|
|
|
|
def test_output_shape_matches_input(self) -> None:
|
|
"""
|
|
Forward pass on a batch of 16 produces output matching input shape.
|
|
"""
|
|
model = ThreatAutoencoder(input_dim=35)
|
|
x = torch.randn(16, 35)
|
|
out = model(x)
|
|
assert out.shape == (16, 35)
|
|
|
|
def test_bottleneck_dim_is_six(self) -> None:
|
|
"""
|
|
Encoder bottleneck compresses 35 features to a 6-dimensional latent vector.
|
|
"""
|
|
model = ThreatAutoencoder(input_dim=35)
|
|
x = torch.randn(4, 35)
|
|
encoded = model.encode(x)
|
|
assert encoded.shape == (4, 6)
|
|
|
|
def test_single_sample_forward(self) -> None:
|
|
"""
|
|
Single-sample forward pass completes without error in eval mode.
|
|
"""
|
|
model = ThreatAutoencoder(input_dim=35)
|
|
model.eval()
|
|
x = torch.randn(1, 35)
|
|
with torch.no_grad():
|
|
out = model(x)
|
|
assert out.shape == (1, 35)
|
|
|
|
def test_output_is_unbounded(self) -> None:
|
|
"""
|
|
Decoder output is unbounded to match RobustScaler-transformed input range.
|
|
"""
|
|
model = ThreatAutoencoder(input_dim=35)
|
|
model.eval()
|
|
x = torch.randn(64, 35) * 3.0
|
|
with torch.no_grad():
|
|
out = model(x)
|
|
assert out.shape == (64, 35)
|
|
assert out.min().item() < 0.0 or out.max().item() > 1.0
|
|
|
|
def test_reconstruction_error_shape(self) -> None:
|
|
"""
|
|
compute_reconstruction_error returns one scalar per sample in the batch.
|
|
"""
|
|
model = ThreatAutoencoder(input_dim=35)
|
|
model.eval()
|
|
x = torch.randn(8, 35)
|
|
with torch.no_grad():
|
|
errors = model.compute_reconstruction_error(x)
|
|
assert errors.shape == (8, )
|
|
|
|
def test_reconstruction_error_positive(self) -> None:
|
|
"""
|
|
Reconstruction error is non-negative for all samples.
|
|
"""
|
|
model = ThreatAutoencoder(input_dim=35)
|
|
model.eval()
|
|
x = torch.randn(8, 35)
|
|
with torch.no_grad():
|
|
errors = model.compute_reconstruction_error(x)
|
|
assert (errors >= 0.0).all()
|
|
|
|
def test_trained_model_reconstructs_normal_better_than_anomaly(
|
|
self) -> None:
|
|
"""
|
|
After training on normal data, reconstruction error is lower for normals than anomalies.
|
|
"""
|
|
torch.manual_seed(42)
|
|
model = ThreatAutoencoder(input_dim=35)
|
|
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
|
|
|
|
normal_data = torch.randn(500, 35) * 0.5 + 0.5
|
|
normal_data = normal_data.clamp(0, 1)
|
|
|
|
model.train()
|
|
for _ in range(50):
|
|
out = model(normal_data)
|
|
loss = torch.nn.functional.mse_loss(out, normal_data)
|
|
optimizer.zero_grad()
|
|
loss.backward()
|
|
optimizer.step()
|
|
|
|
model.eval()
|
|
with torch.no_grad():
|
|
normal_errors = model.compute_reconstruction_error(
|
|
normal_data[:50])
|
|
anomaly_data = torch.rand(50, 35) * 3.0 - 1.0
|
|
anomaly_errors = model.compute_reconstruction_error(anomaly_data)
|
|
|
|
assert anomaly_errors.mean() > normal_errors.mean()
|
|
|
|
def test_eval_mode_disables_dropout(self) -> None:
|
|
"""
|
|
Identical inputs produce identical outputs in eval mode (dropout is off).
|
|
"""
|
|
model = ThreatAutoencoder(input_dim=35)
|
|
model.eval()
|
|
x = torch.randn(4, 35)
|
|
with torch.no_grad():
|
|
out1 = model(x)
|
|
out2 = model(x)
|
|
assert torch.allclose(out1, out2)
|
|
|
|
@pytest.mark.parametrize("batch_size", [1, 8, 32, 128])
|
|
def test_variable_batch_sizes(self, batch_size: int) -> None:
|
|
"""
|
|
Output shape matches input for batch sizes 1, 8, 32, and 128.
|
|
"""
|
|
model = ThreatAutoencoder(input_dim=35)
|
|
model.eval()
|
|
x = torch.randn(batch_size, 35)
|
|
with torch.no_grad():
|
|
out = model(x)
|
|
assert out.shape == (batch_size, 35)
|