Rail_Mind / tests /unit /test_ml_components.py
Gaurav711's picture
Fix type errors, formatting, and unit tests
e6314bb
Raw
History Blame Contribute Delete
10.3 kB
"""
Unit tests for ML components: ensemble_rac, gnn_cascade, railgym.
All tests use small synthetic data to keep CI fast.
"""
import pytest
import numpy as np
import pandas as pd
import torch
# ─────────────────────────────────────────────────────────────
# compute_ece
# ─────────────────────────────────────────────────────────────
def test_compute_ece_perfect_calibration():
from app.ml.ensemble_rac import compute_ece
# Perfect calibration: predicted probs == actual frequencies
y_true = np.array([1, 1, 0, 0, 1, 0, 1, 0])
y_prob = np.array([0.9, 0.8, 0.1, 0.2, 0.7, 0.3, 0.75, 0.25])
ece = compute_ece(y_true, y_prob, n_bins=5)
assert 0.0 <= ece <= 1.0
def test_compute_ece_all_wrong():
from app.ml.ensemble_rac import compute_ece
y_true = np.ones(100)
y_prob = np.zeros(100) # predicts 0 for all 1s
ece = compute_ece(y_true, y_prob, n_bins=10)
assert ece > 0.5
def test_compute_ece_uniform_probs():
from app.ml.ensemble_rac import compute_ece
rng = np.random.default_rng(42)
y_true = rng.integers(0, 2, size=200)
y_prob = np.full(200, 0.5)
ece = compute_ece(y_true, y_prob)
assert 0.0 <= ece <= 1.0
# ─────────────────────────────────────────────────────────────
# get_base_estimators
# ─────────────────────────────────────────────────────────────
def test_get_base_estimators_returns_list():
from app.ml.ensemble_rac import get_base_estimators
estimators = get_base_estimators()
assert len(estimators) >= 2
for name, est in estimators:
assert isinstance(name, str)
assert hasattr(est, "fit")
# ─────────────────────────────────────────────────────────────
# SequentialStackingClassifier
# ─────────────────────────────────────────────────────────────
def test_stacking_classifier_fit_predict():
from app.ml.ensemble_rac import SequentialStackingClassifier, get_base_estimators
from sklearn.linear_model import LogisticRegression
rng = np.random.default_rng(0)
X = pd.DataFrame(rng.random((100, 5)), columns=[f"f{i}" for i in range(5)])
y = rng.integers(0, 2, size=100)
clf = SequentialStackingClassifier(
estimators=get_base_estimators(),
final_estimator=LogisticRegression(),
cv=3,
)
clf.fit(X, y)
preds = clf.predict(X)
assert len(preds) == 100
assert set(preds).issubset({0, 1})
def test_stacking_classifier_predict_proba():
from app.ml.ensemble_rac import SequentialStackingClassifier, get_base_estimators
from sklearn.linear_model import LogisticRegression
rng = np.random.default_rng(1)
X = pd.DataFrame(rng.random((80, 4)), columns=[f"f{i}" for i in range(4)])
y = rng.integers(0, 2, size=80)
clf = SequentialStackingClassifier(
estimators=get_base_estimators(),
final_estimator=LogisticRegression(),
cv=3,
)
clf.fit(X, y)
proba = clf.predict_proba(X)
assert proba.shape == (80, 2)
assert np.allclose(proba.sum(axis=1), 1.0, atol=1e-6)
# ─────────────────────────────────────────────────────────────
# EnsembleRACPredictor
# ─────────────────────────────────────────────────────────────
def _make_rac_dataset(n=120, seed=42):
rng = np.random.default_rng(seed)
X = pd.DataFrame(
{
"waitlist_position": rng.integers(1, 100, size=n),
"rac_count": rng.integers(0, 60, size=n),
"days_to_journey": rng.integers(1, 90, size=n),
"quota_code": rng.integers(0, 4, size=n),
"train_type_code": rng.integers(0, 5, size=n),
}
)
y = ((X["waitlist_position"] < 20) & (X["days_to_journey"] > 7)).astype(int).values
return X, y
def test_ensemble_predictor_fit_and_predict_proba():
from app.ml.ensemble_rac import EnsembleRACPredictor
X, y = _make_rac_dataset(120)
predictor = EnsembleRACPredictor(n_bins=5)
predictor.fit(X, y)
proba = predictor.predict_proba(X.iloc[:10])
assert len(proba) == 10
assert all(0.0 <= p <= 1.0 for p in proba)
def test_ensemble_predictor_raises_if_not_fitted():
from app.ml.ensemble_rac import EnsembleRACPredictor
predictor = EnsembleRACPredictor()
X = pd.DataFrame({"a": [1, 2]})
with pytest.raises(RuntimeError, match="not fitted"):
predictor.predict_proba(X)
def test_ensemble_predictor_evaluate():
from app.ml.ensemble_rac import EnsembleRACPredictor
X, y = _make_rac_dataset(150)
predictor = EnsembleRACPredictor(n_bins=5)
predictor.fit(X, y)
metrics = predictor.evaluate(X, y)
assert "auc" in metrics or "roc_auc" in metrics or "accuracy" in metrics
# ─────────────────────────────────────────────────────────────
# RailwayGNN
# ─────────────────────────────────────────────────────────────
def test_railway_gnn_forward_pass():
from app.ml.gnn_cascade import RailwayGNN
model = RailwayGNN(node_features=8, hidden_dim=32, num_layers=2)
# Minimal graph: 5 nodes, 4 edges
x = torch.randn(5, 8)
edge_index = torch.tensor([[0, 1, 2, 3], [1, 2, 3, 4]], dtype=torch.long)
edge_attr = torch.randn(4, 4)
out = model(x, edge_index, edge_attr)
assert out.shape[0] == 5
assert not torch.isnan(out).any()
def test_railway_gnn_single_node():
from app.ml.gnn_cascade import RailwayGNN
model = RailwayGNN(node_features=8, hidden_dim=16, num_layers=1)
x = torch.randn(1, 8)
edge_index = torch.zeros((2, 0), dtype=torch.long)
edge_attr = torch.zeros((0, 4))
out = model(x, edge_index, edge_attr)
assert out.shape[0] == 1
def test_cascade_loss_forward():
from app.ml.gnn_cascade import CascadeLoss
loss_fn = CascadeLoss(alpha=0.7, beta=0.3)
predictions = torch.sigmoid(torch.randn(10))
targets = torch.randint(0, 2, (10,)).float()
cascade_weights = torch.rand(10)
loss = loss_fn(predictions, targets, cascade_weights)
assert loss.item() >= 0.0
assert not torch.isnan(loss)
# ─────────────────────────────────────────────────────────────
# RailGym
# ─────────────────────────────────────────────────────────────
def test_railgym_reset():
from app.ml.railgym import RailGym
env = RailGym(scenario="normal")
obs, info = env.reset(seed=42)
assert obs.shape == (RailGym.N_SECTIONS * 7,)
assert obs.dtype == np.float32
assert isinstance(info, dict)
def test_railgym_step():
from app.ml.railgym import RailGym
env = RailGym(scenario="moderate")
env.reset(seed=0)
action = env.action_space.sample()
obs, reward, terminated, truncated, info = env.step(action)
assert obs.shape == (RailGym.N_SECTIONS * 7,)
assert isinstance(reward, float)
assert isinstance(terminated, bool)
def test_railgym_full_episode():
from app.ml.railgym import RailGym
env = RailGym(scenario="severe")
obs, _ = env.reset(seed=7)
total_reward = 0.0
steps = 0
terminated = False
while not terminated:
action = env.action_space.sample()
obs, reward, terminated, truncated, info = env.step(action)
total_reward += reward
steps += 1
if steps > 100:
break
assert steps == env._max_steps or terminated
def test_railgym_scenarios():
from app.ml.railgym import RailGym
for scenario in ["normal", "moderate", "severe", "fog"]:
env = RailGym(scenario=scenario)
obs, _ = env.reset(seed=1)
assert obs is not None
def test_railgym_observation_space_valid():
from app.ml.railgym import RailGym
env = RailGym()
obs, _ = env.reset()
assert env.observation_space.contains(obs)
def test_railgym_action_space_shape():
from app.ml.railgym import RailGym
env = RailGym()
assert env.action_space.shape == (RailGym.N_SECTIONS,)
@pytest.mark.skip(reason="XGBoost segfaults in pytest environment")
def test_train_rac_model():
from unittest.mock import patch
from app.ml.train_rac_model import train_and_save_model, generate_synthetic_data
df = generate_synthetic_data(10)
assert len(df) == 10
assert "confirmed" in df.columns
with patch("joblib.dump") as mock_dump, patch("pathlib.Path.mkdir") as mock_mkdir:
train_and_save_model()
mock_mkdir.assert_called()
assert mock_dump.call_count == 2
def test_ensemble_predict_proba_no_frozen_estimator_error():
"""
Regression test: CalibratedClassifierCV(cv='prefit') on a StackingClassifier
raised 'FrozenEstimator should be a classifier' on sklearn>=1.6.
Fix: calibrate base estimators individually, stack without post-hoc calibration.
"""
from app.ml.ensemble_rac import EnsembleRACPredictor
X, y = _make_rac_dataset(100)
predictor = EnsembleRACPredictor()
predictor.fit(X, y)
probs = predictor.predict_proba(X.iloc[:10])
assert probs.shape == (10,)
assert (probs >= 0.0).all() and (probs <= 1.0).all()