|
|
|
|
|
|
|
import pytest |
|
import numpy as np |
|
import pandas as pd |
|
import tempfile |
|
import sys |
|
import os |
|
from pathlib import Path |
|
from unittest.mock import patch |
|
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) |
|
|
|
@pytest.fixture(scope="session") |
|
def test_data_dir(): |
|
"""Create temporary directory for test data""" |
|
with tempfile.TemporaryDirectory() as temp_dir: |
|
yield Path(temp_dir) |
|
|
|
@pytest.fixture(scope="session") |
|
def sample_fake_news_data(): |
|
"""Generate realistic fake news dataset for testing""" |
|
np.random.seed(42) |
|
|
|
|
|
fake_texts = [ |
|
"BREAKING: Scientists discover shocking truth about vaccines that doctors don't want you to know!", |
|
"EXCLUSIVE: Celebrity caught in major scandal - you won't believe what happened next!", |
|
"ALERT: Government secretly planning massive operation - leaked documents reveal everything!", |
|
"AMAZING: Local mom discovers one weird trick that makes millions - experts hate her!", |
|
"URGENT: New study proves everything you know about nutrition is completely wrong!", |
|
] * 20 |
|
|
|
|
|
real_texts = [ |
|
"Local city council approves new infrastructure budget for road maintenance and repairs.", |
|
"University researchers publish peer-reviewed study on climate change impacts in regional ecosystems.", |
|
"Stock market shows mixed results following quarterly earnings reports from major corporations.", |
|
"Public health officials recommend updated vaccination schedules based on recent clinical trials.", |
|
"Municipal government announces new public transportation routes to improve city connectivity.", |
|
] * 20 |
|
|
|
|
|
all_texts = fake_texts + real_texts |
|
all_labels = [1] * len(fake_texts) + [0] * len(real_texts) |
|
|
|
df = pd.DataFrame({ |
|
'text': all_texts, |
|
'label': all_labels |
|
}) |
|
|
|
return df.sample(frac=1, random_state=42).reset_index(drop=True) |
|
|
|
@pytest.fixture |
|
def mock_enhanced_features(): |
|
"""Mock enhanced feature engineering when not available""" |
|
with patch('model.retrain.ENHANCED_FEATURES_AVAILABLE', True): |
|
with patch('model.retrain.AdvancedFeatureEngineer') as mock_fe: |
|
|
|
mock_instance = mock_fe.return_value |
|
mock_instance.get_feature_metadata.return_value = { |
|
'total_features': 5000, |
|
'feature_types': { |
|
'tfidf_features': 3000, |
|
'sentiment_features': 10, |
|
'readability_features': 15, |
|
'entity_features': 25, |
|
'linguistic_features': 50 |
|
}, |
|
'configuration': {'test': True} |
|
} |
|
mock_instance.get_feature_importance.return_value = { |
|
'feature_1': 0.15, |
|
'feature_2': 0.12, |
|
'feature_3': 0.10 |
|
} |
|
mock_instance.get_feature_names.return_value = [f'feature_{i}' for i in range(5000)] |
|
|
|
yield mock_fe |
|
|
|
|
|
|
|
|
|
import pytest |
|
import pandas as pd |
|
import numpy as np |
|
from pathlib import Path |
|
import tempfile |
|
|
|
from data.data_validator import DataValidator |
|
from data.prepare_datasets import DatasetPreparer |
|
|
|
class TestDataValidation: |
|
"""Test data validation functionality""" |
|
|
|
def test_validate_text_column(self, sample_fake_news_data): |
|
"""Test text column validation""" |
|
validator = DataValidator() |
|
|
|
|
|
is_valid, issues = validator.validate_dataframe(sample_fake_news_data) |
|
assert is_valid == True |
|
assert len(issues) == 0 |
|
|
|
|
|
invalid_data = pd.DataFrame({ |
|
'text': ['', 'x', None, 'Valid text here'], |
|
'label': [0, 1, 0, 2] |
|
}) |
|
|
|
is_valid, issues = validator.validate_dataframe(invalid_data) |
|
assert is_valid == False |
|
assert len(issues) > 0 |
|
|
|
def test_text_quality_validation(self): |
|
"""Test text quality validation rules""" |
|
validator = DataValidator() |
|
|
|
|
|
short_texts = pd.DataFrame({ |
|
'text': ['hi', 'ok', 'This is a proper length text for validation'], |
|
'label': [0, 1, 0] |
|
}) |
|
|
|
is_valid, issues = validator.validate_dataframe(short_texts) |
|
assert is_valid == False |
|
assert any('length' in str(issue).lower() for issue in issues) |
|
|
|
|
|
|
|
|
|
|
|
import pytest |
|
import tempfile |
|
from pathlib import Path |
|
from unittest.mock import patch |
|
|
|
class TestTrainRetrainCompatibility: |
|
"""Test compatibility between train.py and retrain.py""" |
|
|
|
def test_metadata_compatibility(self): |
|
"""Test metadata format compatibility between train and retrain""" |
|
from model.train import EnhancedModelTrainer |
|
from model.retrain import EnhancedModelRetrainer |
|
|
|
with tempfile.TemporaryDirectory() as temp_dir: |
|
temp_path = Path(temp_dir) |
|
|
|
|
|
trainer = EnhancedModelTrainer(use_enhanced_features=False) |
|
trainer.base_dir = temp_path |
|
trainer.setup_paths() |
|
|
|
|
|
sample_metadata = { |
|
'model_version': 'v1.0', |
|
'model_type': 'enhanced_pipeline_cv', |
|
'feature_engineering': {'type': 'standard'}, |
|
'test_f1': 0.85, |
|
'cross_validation': { |
|
'test_scores': {'f1': {'mean': 0.82, 'std': 0.03}} |
|
} |
|
} |
|
|
|
|
|
import json |
|
with open(trainer.metadata_path, 'w') as f: |
|
json.dump(sample_metadata, f) |
|
|
|
|
|
retrainer = EnhancedModelRetrainer() |
|
retrainer.base_dir = temp_path |
|
retrainer.setup_paths() |
|
|
|
metadata = retrainer.load_existing_metadata() |
|
assert metadata is not None |
|
assert metadata['model_version'] == 'v1.0' |
|
assert metadata['feature_engineering']['type'] == 'standard' |
|
|
|
def test_model_file_compatibility(self): |
|
"""Test model file format compatibility""" |
|
|
|
from model.retrain import EnhancedModelRetrainer |
|
|
|
with tempfile.TemporaryDirectory() as temp_dir: |
|
temp_path = Path(temp_dir) |
|
|
|
retrainer = EnhancedModelRetrainer() |
|
retrainer.base_dir = temp_path |
|
retrainer.setup_paths() |
|
|
|
|
|
from sklearn.pipeline import Pipeline |
|
from sklearn.linear_model import LogisticRegression |
|
from sklearn.feature_extraction.text import TfidfVectorizer |
|
|
|
mock_pipeline = Pipeline([ |
|
('vectorize', TfidfVectorizer(max_features=1000)), |
|
('model', LogisticRegression()) |
|
]) |
|
|
|
import joblib |
|
joblib.dump(mock_pipeline, retrainer.prod_pipeline_path) |
|
|
|
|
|
success, model, message = retrainer.load_production_model() |
|
assert success == True |
|
assert model is not None |
|
|
|
|
|
|
|
|
|
[tool:pytest] |
|
testpaths = tests |
|
python_files = test_*.py |
|
python_classes = Test* |
|
python_functions = test_* |
|
addopts = |
|
-v |
|
--tb=short |
|
--strict-markers |
|
--disable-warnings |
|
--color=yes |
|
markers = |
|
slow: marks tests as slow (deselect with '-m "not slow"') |
|
integration: marks tests as integration tests |
|
unit: marks tests as unit tests |
|
cpu_constraint: marks tests that verify CPU constraint compliance |
|
filterwarnings = |
|
ignore::UserWarning |
|
ignore::FutureWarning |
|
ignore::DeprecationWarning |
|
|
|
|
|
|
|
|
|
|
|
import pytest |
|
import numpy as np |
|
from unittest.mock import patch |
|
import lightgbm as lgb |
|
|
|
class TestLightGBMIntegration: |
|
"""Test LightGBM-specific functionality""" |
|
|
|
def test_lightgbm_model_configuration(self): |
|
"""Test LightGBM model is properly configured for CPU constraints""" |
|
from model.retrain import EnhancedModelRetrainer |
|
|
|
retrainer = EnhancedModelRetrainer() |
|
lgb_config = retrainer.models['lightgbm'] |
|
lgb_model = lgb_config['model'] |
|
|
|
|
|
assert isinstance(lgb_model, lgb.LGBMClassifier) |
|
assert lgb_model.n_jobs == 1 |
|
assert lgb_model.verbose == -1 |
|
assert lgb_model.n_estimators <= 100 |
|
assert lgb_model.num_leaves <= 31 |
|
|
|
|
|
param_grid = lgb_config['param_grid'] |
|
assert all(est <= 100 for est in param_grid['model__n_estimators']) |
|
assert all(leaves <= 31 for leaves in param_grid['model__num_leaves']) |
|
|
|
def test_lightgbm_training_integration(self): |
|
"""Test LightGBM integrates properly in training pipeline""" |
|
from model.retrain import EnhancedModelRetrainer |
|
|
|
|
|
X = np.random.randn(50, 10) |
|
y = np.random.randint(0, 2, 50) |
|
|
|
retrainer = EnhancedModelRetrainer() |
|
retrainer.use_enhanced_features = False |
|
|
|
|
|
pipeline = retrainer.create_preprocessing_pipeline() |
|
|
|
try: |
|
best_model, results = retrainer.hyperparameter_tuning_with_cv( |
|
pipeline, X, y, 'lightgbm' |
|
) |
|
|
|
|
|
assert best_model is not None |
|
assert 'cross_validation' in results or 'error' in results |
|
|
|
except Exception as e: |
|
|
|
assert 'fallback' in str(e).lower() or 'error' in str(e).lower() |
|
|
|
def test_lightgbm_cpu_performance(self): |
|
"""Test LightGBM performance is acceptable under CPU constraints""" |
|
import time |
|
from model.retrain import EnhancedModelRetrainer |
|
|
|
|
|
X = np.random.randn(200, 20) |
|
y = np.random.randint(0, 2, 200) |
|
|
|
retrainer = EnhancedModelRetrainer() |
|
pipeline = retrainer.create_preprocessing_pipeline() |
|
lgb_model = retrainer.models['lightgbm']['model'] |
|
pipeline.set_params(model=lgb_model) |
|
|
|
|
|
start_time = time.time() |
|
pipeline.fit(X, y) |
|
training_time = time.time() - start_time |
|
|
|
|
|
assert training_time < 30 |
|
|
|
|
|
predictions = pipeline.predict(X[:10]) |
|
assert len(predictions) == 10 |
|
assert all(pred in [0, 1] for pred in predictions) |
|
|
|
|
|
|
|
|
|
|
|
import pytest |
|
import numpy as np |
|
from scipy import stats |
|
from unittest.mock import Mock, patch |
|
|
|
class TestEnsembleStatisticalValidation: |
|
"""Test statistical validation for ensemble selection""" |
|
|
|
def test_paired_ttest_ensemble_selection(self): |
|
"""Test paired t-test logic for ensemble vs individual models""" |
|
from model.retrain import CVModelComparator |
|
|
|
comparator = CVModelComparator(cv_folds=5, random_state=42) |
|
|
|
|
|
individual_scores = [0.75, 0.74, 0.76, 0.73, 0.75] |
|
ensemble_scores = [0.80, 0.81, 0.79, 0.78, 0.82] |
|
|
|
|
|
comparison = comparator._compare_metric_scores( |
|
individual_scores, ensemble_scores, 'f1', 'individual', 'ensemble' |
|
) |
|
|
|
assert 'tests' in comparison |
|
assert 'paired_ttest' in comparison['tests'] |
|
|
|
|
|
t_test_result = comparison['tests']['paired_ttest'] |
|
assert 'p_value' in t_test_result |
|
assert 'significant' in t_test_result |
|
|
|
|
|
if t_test_result['p_value'] is not None: |
|
assert t_test_result['significant'] == True |
|
|
|
def test_ensemble_not_selected_when_not_significant(self): |
|
"""Test ensemble is not selected when improvement is not significant""" |
|
from model.retrain import CVModelComparator |
|
|
|
comparator = CVModelComparator(cv_folds=5, random_state=42) |
|
|
|
|
|
individual_scores = [0.75, 0.74, 0.76, 0.73, 0.75] |
|
ensemble_scores = [0.751, 0.741, 0.761, 0.731, 0.751] |
|
|
|
comparison = comparator._compare_metric_scores( |
|
individual_scores, ensemble_scores, 'f1', 'individual', 'ensemble' |
|
) |
|
|
|
|
|
assert comparison['significant_improvement'] == False |
|
|
|
def test_effect_size_calculation(self): |
|
"""Test Cohen's d effect size calculation""" |
|
from model.retrain import CVModelComparator |
|
|
|
comparator = CVModelComparator(cv_folds=5, random_state=42) |
|
|
|
|
|
individual_scores = [0.70, 0.71, 0.69, 0.72, 0.70] |
|
ensemble_scores = [0.80, 0.81, 0.79, 0.82, 0.80] |
|
|
|
comparison = comparator._compare_metric_scores( |
|
individual_scores, ensemble_scores, 'f1', 'individual', 'ensemble' |
|
) |
|
|
|
assert 'effect_size' in comparison |
|
effect_size = comparison['effect_size'] |
|
|
|
|
|
assert abs(effect_size) > 0.5 |
|
|
|
def test_promotion_decision_with_feature_upgrade(self): |
|
"""Test promotion decision considers feature engineering upgrades""" |
|
from model.retrain import CVModelComparator |
|
|
|
comparator = CVModelComparator() |
|
|
|
|
|
mock_results = { |
|
'metric_comparisons': { |
|
'f1': { |
|
'improvement': 0.008, |
|
'significant_improvement': False |
|
}, |
|
'accuracy': { |
|
'improvement': 0.005, |
|
'significant_improvement': False |
|
} |
|
}, |
|
'feature_engineering_comparison': { |
|
'feature_upgrade': { |
|
'is_upgrade': True, |
|
'upgrade_type': 'standard_to_enhanced' |
|
} |
|
} |
|
} |
|
|
|
decision = comparator._make_enhanced_promotion_decision(mock_results) |
|
|
|
|
|
assert decision['promote_candidate'] == True |
|
assert decision['feature_engineering_factor'] == True |
|
assert 'feature' in decision['reason'].lower() |
|
|
|
|
|
|
|
|
|
|
|
import pytest |
|
import sys |
|
from pathlib import Path |
|
|
|
def run_unit_tests(): |
|
"""Run fast unit tests""" |
|
return pytest.main([ |
|
"tests/", |
|
"-m", "not slow and not integration", |
|
"-v", |
|
"--tb=short" |
|
]) |
|
|
|
def run_integration_tests(): |
|
"""Run slower integration tests""" |
|
return pytest.main([ |
|
"tests/", |
|
"-m", "integration", |
|
"-v", |
|
"--tb=short" |
|
]) |
|
|
|
def run_cpu_constraint_tests(): |
|
"""Run tests that verify CPU constraint compliance""" |
|
return pytest.main([ |
|
"tests/", |
|
"-m", "cpu_constraint", |
|
"-v", |
|
"--tb=short" |
|
]) |
|
|
|
def run_all_tests(): |
|
"""Run complete test suite""" |
|
return pytest.main([ |
|
"tests/", |
|
"-v", |
|
"--tb=short", |
|
"--cov=model", |
|
"--cov-report=html" |
|
]) |
|
|
|
if __name__ == "__main__": |
|
if len(sys.argv) > 1: |
|
test_type = sys.argv[1] |
|
if test_type == "unit": |
|
exit_code = run_unit_tests() |
|
elif test_type == "integration": |
|
exit_code = run_integration_tests() |
|
elif test_type == "cpu": |
|
exit_code = run_cpu_constraint_tests() |
|
elif test_type == "all": |
|
exit_code = run_all_tests() |
|
else: |
|
print("Usage: python run_tests.py [unit|integration|cpu|all]") |
|
exit_code = 1 |
|
else: |
|
exit_code = run_unit_tests() |
|
|
|
sys.exit(exit_code) |