diff --git a/AnomalyDetection/__pycache__/AnomalyDetection.cpython-314.pyc b/AnomalyDetection/__pycache__/AnomalyDetection.cpython-314.pyc new file mode 100644 index 0000000..0743a78 Binary files /dev/null and b/AnomalyDetection/__pycache__/AnomalyDetection.cpython-314.pyc differ diff --git a/K-Means/__pycache__/K-Menas.cpython-314.pyc b/K-Means/__pycache__/K-Menas.cpython-314.pyc new file mode 100644 index 0000000..898ddcf Binary files /dev/null and b/K-Means/__pycache__/K-Menas.cpython-314.pyc differ diff --git a/LinearRegression/__pycache__/LinearRegression.cpython-314.pyc b/LinearRegression/__pycache__/LinearRegression.cpython-314.pyc new file mode 100644 index 0000000..faa696c Binary files /dev/null and b/LinearRegression/__pycache__/LinearRegression.cpython-314.pyc differ diff --git a/LogisticRegression/__pycache__/LogisticRegression.cpython-314.pyc b/LogisticRegression/__pycache__/LogisticRegression.cpython-314.pyc new file mode 100644 index 0000000..69a4053 Binary files /dev/null and b/LogisticRegression/__pycache__/LogisticRegression.cpython-314.pyc differ diff --git a/NeuralNetwok/__pycache__/NeuralNetwork.cpython-314.pyc b/NeuralNetwok/__pycache__/NeuralNetwork.cpython-314.pyc new file mode 100644 index 0000000..fb4f296 Binary files /dev/null and b/NeuralNetwok/__pycache__/NeuralNetwork.cpython-314.pyc differ diff --git a/PCA/PCA.py b/PCA/PCA.py index 2301ade..1b2390c 100644 --- a/PCA/PCA.py +++ b/PCA/PCA.py @@ -4,7 +4,7 @@ import numpy as np from matplotlib import pyplot as plt from scipy import io as spio -from sklearn.decomposition import pca +from sklearn.decomposition import PCA as SklearnPCA ''' 主成分分析_2维数据降维1维演示函数 diff --git a/PCA/__pycache__/PCA.cpython-314.pyc b/PCA/__pycache__/PCA.cpython-314.pyc new file mode 100644 index 0000000..1f82ebe Binary files /dev/null and b/PCA/__pycache__/PCA.cpython-314.pyc differ diff --git a/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..e199de1 Binary files /dev/null and b/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_anomaly_detection.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_anomaly_detection.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..11a1042 Binary files /dev/null and b/tests/__pycache__/test_anomaly_detection.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_kmeans.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_kmeans.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..a95f729 Binary files /dev/null and b/tests/__pycache__/test_kmeans.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_linear_regression.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_linear_regression.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..e625ec9 Binary files /dev/null and b/tests/__pycache__/test_linear_regression.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_logistic_regression.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_logistic_regression.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..3d09d09 Binary files /dev/null and b/tests/__pycache__/test_logistic_regression.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_neural_network.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_neural_network.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..26e998c Binary files /dev/null and b/tests/__pycache__/test_neural_network.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_pca.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_pca.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..7ede265 Binary files /dev/null and b/tests/__pycache__/test_pca.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_svm.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_svm.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..2c15f5f Binary files /dev/null and b/tests/__pycache__/test_svm.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fd6c3cd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +""" +Pytest 共享 fixtures 配置文件 +为所有机器学习算法测试提供公共的数据加载和模型初始化功能 +""" +import pytest +import numpy as np +import os +import sys + +# 添加项目根目录到 Python 路径 +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, PROJECT_ROOT) + + +@pytest.fixture +def sample_regression_data(): + """生成小型回归数据集用于测试""" + np.random.seed(42) + m = 50 + X = np.random.randn(m, 2) + true_theta = np.array([2.0, -1.5]) + y = X[:, 0] * true_theta[0] + X[:, 1] * true_theta[1] + 0.1 * np.random.randn(m) + return X, y, true_theta + + +@pytest.fixture +def sample_classification_data(): + """生成小型二分类数据集用于测试""" + np.random.seed(42) + m = 100 + # 生成两类数据 + X_pos = np.random.randn(m // 2, 2) + np.array([1.0, 1.0]) + X_neg = np.random.randn(m // 2, 2) - np.array([1.0, 1.0]) + X = np.vstack([X_pos, X_neg]) + y = np.hstack([np.ones(m // 2), np.zeros(m // 2)]) + return X, y + + +@pytest.fixture +def sample_multiclass_data(): + """生成小型多分类数据集用于测试""" + np.random.seed(42) + m = 150 + n_classes = 3 + X_list = [] + y_list = [] + for i in range(n_classes): + center = np.array([np.cos(2 * np.pi * i / n_classes), + np.sin(2 * np.pi * i / n_classes)]) * 2 + X_list.append(np.random.randn(m // n_classes, 2) + center) + y_list.append(np.ones(m // n_classes) * i) + X = np.vstack(X_list) + y = np.hstack(y_list) + return X, y.astype(int) + + +@pytest.fixture +def sample_clustering_data(): + """生成小型聚类数据集用于测试""" + np.random.seed(42) + K = 3 + m_per_cluster = 30 + centroids = np.array([[0, 0], [5, 5], [10, 0]]) + X_list = [] + for i in range(K): + X_list.append(np.random.randn(m_per_cluster, 2) + centroids[i]) + X = np.vstack(X_list) + return X, K, centroids + + +@pytest.fixture +def linear_regression_data_path(): + """线性回归数据文件路径""" + return os.path.join(PROJECT_ROOT, "LinearRegression", "data.txt") + + +@pytest.fixture +def logistic_regression_data_path(): + """逻辑回归数据文件路径""" + return os.path.join(PROJECT_ROOT, "LogisticRegression", "data2.txt") + + +@pytest.fixture +def kmeans_data_path(): + """K-Means 数据文件路径""" + return os.path.join(PROJECT_ROOT, "K-Means", "data.mat") + + +@pytest.fixture +def pca_data_path(): + """PCA 数据文件路径""" + return os.path.join(PROJECT_ROOT, "PCA", "data.mat") + + +@pytest.fixture +def anomaly_detection_data_path(): + """异常检测数据文件路径""" + return os.path.join(PROJECT_ROOT, "AnomalyDetection", "data1.mat") + + +@pytest.fixture +def neural_network_data_path(): + """神经网络数据文件路径""" + return os.path.join(PROJECT_ROOT, "NeuralNetwok", "data_digits.mat") + + +@pytest.fixture +def svm_data_path(): + """SVM 数据文件路径""" + return os.path.join(PROJECT_ROOT, "SVM", "data1.mat") diff --git a/tests/test_anomaly_detection.py b/tests/test_anomaly_detection.py new file mode 100644 index 0000000..3948028 --- /dev/null +++ b/tests/test_anomaly_detection.py @@ -0,0 +1,404 @@ +# -*- coding: utf-8 -*- +""" +异常检测算法测试模块 +测试内容: +- 数据加载功能 +- 高斯分布参数估计 +- 多元高斯概率计算 +- 阈值选择 +- F1分数计算 +- 端到端集成测试 +""" +import pytest +import numpy as np +import os +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, PROJECT_ROOT) + +# 动态导入模块 +import importlib.util +spec = importlib.util.spec_from_file_location("AnomalyDetection", + os.path.join(PROJECT_ROOT, "AnomalyDetection", "AnomalyDetection.py")) +AnomalyDetection = importlib.util.module_from_spec(spec) +spec.loader.exec_module(AnomalyDetection) + +estimateGaussian = AnomalyDetection.estimateGaussian +multivariateGaussian = AnomalyDetection.multivariateGaussian +selectThreshold = AnomalyDetection.selectThreshold +display_2d_data = AnomalyDetection.display_2d_data + +from scipy import io as spio + + +class TestDataLoading: + """数据加载测试类""" + + def test_load_mat_data(self, anomaly_detection_data_path): + """测试 mat 文件加载""" + if os.path.exists(anomaly_detection_data_path): + data = spio.loadmat(anomaly_detection_data_path) + assert isinstance(data, dict) + assert 'X' in data + X = data['X'] + assert isinstance(X, np.ndarray) + assert X.ndim == 2 + + def test_load_validation_data(self, anomaly_detection_data_path): + """测试加载验证集""" + if os.path.exists(anomaly_detection_data_path): + data = spio.loadmat(anomaly_detection_data_path) + # 检查是否有验证集 + if 'Xval' in data and 'yval' in data: + Xval = data['Xval'] + yval = data['yval'] + assert Xval.ndim == 2 + assert yval.ndim == 2 + assert Xval.shape[0] == yval.shape[0] + + +class TestEstimateGaussian: + """高斯参数估计测试类""" + + def test_estimate_gaussian_shape(self): + """测试输出形状""" + np.random.seed(42) + X = np.random.randn(100, 3) + mu, sigma2 = estimateGaussian(X) + assert mu.shape == (3,) + assert sigma2.shape == (3,) + + def test_estimate_gaussian_mean(self): + """测试均值估计""" + np.random.seed(42) + true_mean = np.array([1, 2, 3]) + X = np.random.randn(1000, 3) + true_mean + mu, sigma2 = estimateGaussian(X) + np.testing.assert_array_almost_equal(mu, true_mean, decimal=1) + + def test_estimate_gaussian_variance(self): + """测试方差估计""" + np.random.seed(42) + X = np.random.randn(1000, 2) + mu, sigma2 = estimateGaussian(X) + # 标准正态分布方差应为1 + np.testing.assert_array_almost_equal(sigma2, [1, 1], decimal=1) + + def test_estimate_gaussian_known_data(self): + """测试已知数据""" + X = np.array([[1, 2], [3, 4], [5, 6]]) + mu, sigma2 = estimateGaussian(X) + expected_mu = np.array([3, 4]) + expected_var = np.array([4, 4]) # 方差 + np.testing.assert_array_equal(mu, expected_mu) + np.testing.assert_array_equal(sigma2, expected_var) + + +class TestMultivariateGaussian: + """多元高斯分布测试类""" + + def test_multivariate_gaussian_shape(self): + """测试输出形状""" + X = np.array([[1, 2], [3, 4]]) + mu = np.array([0, 0]) + sigma2 = np.array([1, 1]) + p = multivariateGaussian(X, mu, sigma2) + assert p.shape == (2,) + + def test_multivariate_gaussian_range(self): + """测试概率范围""" + np.random.seed(42) + X = np.random.randn(100, 2) + mu = np.array([0, 0]) + sigma2 = np.array([1, 1]) + p = multivariateGaussian(X, mu, sigma2) + assert np.all(p > 0) + assert np.all(p <= 1) + + def test_multivariate_gaussian_at_mean(self): + """测试均值处概率最大""" + mu = np.array([0, 0]) + sigma2 = np.array([1, 1]) + + # 均值处的概率 + p_mean = multivariateGaussian(mu.reshape(1, -1), mu, sigma2) + + # 远离均值的点 + X_far = np.array([[5, 5]]) + p_far = multivariateGaussian(X_far, mu, sigma2) + + assert p_mean[0] > p_far[0] + + def test_multivariate_gaussian_symmetry(self): + """测试对称性""" + mu = np.array([0, 0]) + sigma2 = np.array([1, 1]) + + X1 = np.array([[1, 0]]) + X2 = np.array([[-1, 0]]) + + p1 = multivariateGaussian(X1, mu, sigma2) + p2 = multivariateGaussian(X2, mu, sigma2) + + np.testing.assert_almost_equal(p1[0], p2[0]) + + def test_multivariate_gaussian_diagonal_covariance(self): + """测试对角协方差""" + X = np.array([[0, 0]]) + mu = np.array([0, 0]) + sigma2 = np.array([4, 9]) # 不同方差 + + p = multivariateGaussian(X, mu, sigma2) + + # 在均值处,概率密度应为 (2*pi)^(-k/2) * |Sigma|^(-1/2) + # = (2*pi)^(-1) * (4*9)^(-1/2) = 1/(2*pi*6) + expected = 1 / (2 * np.pi * 6) + np.testing.assert_almost_equal(p[0], expected, decimal=5) + + +class TestSelectThreshold: + """阈值选择测试类""" + + def test_select_threshold_output(self): + """测试输出类型""" + np.random.seed(42) + yval = np.array([0, 0, 0, 1, 1]) + pval = np.array([0.1, 0.2, 0.3, 0.4, 0.5]) + + epsilon, F1 = selectThreshold(yval, pval) + assert isinstance(epsilon, (float, np.floating)) + assert isinstance(F1, (float, np.floating)) + assert 0 <= F1 <= 1 + + def test_select_threshold_perfect_case(self): + """测试完美分离情况""" + # 正常点概率高,异常点概率低 + yval = np.array([0, 0, 0, 1, 1]) + pval = np.array([0.9, 0.8, 0.7, 0.1, 0.05]) + + epsilon, F1 = selectThreshold(yval, pval) + + # F1应接近1 + assert F1 > 0.8 + # epsilon应在正常和异常概率之间 + assert 0.05 < epsilon < 0.9 + + def test_select_threshold_all_normal(self): + """测试全正常情况""" + yval = np.array([0, 0, 0, 0, 0]) + pval = np.array([0.1, 0.2, 0.3, 0.4, 0.5]) + + epsilon, F1 = selectThreshold(yval, pval) + + # 全正常时F1应为0(无法计算精确率和召回率) + assert F1 == 0 or np.isnan(F1) + + def test_select_threshold_all_anomaly(self): + """测试全异常情况""" + yval = np.array([1, 1, 1, 1, 1]) + pval = np.array([0.1, 0.2, 0.3, 0.4, 0.5]) + + epsilon, F1 = selectThreshold(yval, pval) + + # 全异常时F1应为0 + assert F1 == 0 or np.isnan(F1) + + +class TestIntegration: + """集成测试类""" + + def test_anomaly_detection_workflow(self): + """异常检测完整工作流""" + np.random.seed(42) + + # 生成正常数据 + X_normal = np.random.randn(300, 2) + + # 生成异常数据 + X_anomaly = np.random.randn(20, 2) * 0.5 + np.array([5, 5]) + + # 合并 + X = np.vstack([X_normal, X_anomaly]) + + # 创建验证集标签 + yval = np.hstack([np.zeros(300), np.ones(20)]) + + # 估计高斯参数 + mu, sigma2 = estimateGaussian(X) + + # 计算概率 + p = multivariateGaussian(X, mu, sigma2) + + # 选择阈值 + epsilon, F1 = selectThreshold(yval, p) + + # 检测异常 + outliers = p < epsilon + + # 验证 + assert len(outliers) == len(X) + # 应检测到一些异常 + assert np.sum(outliers) > 0 + # F1应合理 + assert 0 <= F1 <= 1 + + def test_anomaly_detection_2d(self): + """二维数据异常检测""" + np.random.seed(42) + + # 生成二维正态数据 + X_train = np.random.randn(200, 2) + + # 验证集包含正常和异常 + X_val_normal = np.random.randn(50, 2) + X_val_anomaly = np.random.randn(10, 2) * 0.5 + np.array([4, 4]) + X_val = np.vstack([X_val_normal, X_val_anomaly]) + y_val = np.hstack([np.zeros(50), np.ones(10)]) + + # 训练 + mu, sigma2 = estimateGaussian(X_train) + p_val = multivariateGaussian(X_val, mu, sigma2) + + # 选择阈值 + epsilon, F1 = selectThreshold(y_val, p_val) + + # 在训练集上检测 + p_train = multivariateGaussian(X_train, mu, sigma2) + outliers_train = np.sum(p_train < epsilon) + + # 训练集异常比例应较低 + assert outliers_train / len(X_train) < 0.2 + + def test_different_sigma_values(self): + """测试不同方差值""" + np.random.seed(42) + + # 生成不同方差的数据 + X_low_var = np.random.randn(100, 2) * 0.5 + X_high_var = np.random.randn(100, 2) * 2 + + for X in [X_low_var, X_high_var]: + mu, sigma2 = estimateGaussian(X) + p = multivariateGaussian(X, mu, sigma2) + + # 概率应在合理范围内 + assert np.all(p > 0) + assert np.all(p <= 1) + + +class TestEdgeCases: + """边界情况测试类""" + + def test_single_feature(self): + """测试单特征""" + X = np.random.randn(100, 1) + mu, sigma2 = estimateGaussian(X) + p = multivariateGaussian(X, mu, sigma2) + + assert mu.shape == (1,) + assert sigma2.shape == (1,) + assert p.shape == (100,) + + def test_single_sample(self): + """测试单样本""" + X = np.array([[1, 2]]) + mu, sigma2 = estimateGaussian(X) + + # 单样本方差为0 + assert sigma2[0] == 0 + assert sigma2[1] == 0 + + def test_high_dimensional_data(self): + """测试高维数据""" + np.random.seed(42) + X = np.random.randn(50, 20) + mu, sigma2 = estimateGaussian(X) + p = multivariateGaussian(X, mu, sigma2) + + assert mu.shape == (20,) + assert sigma2.shape == (20,) + assert p.shape == (50,) + assert np.all(p > 0) + + def test_correlated_features(self): + """测试相关特征""" + np.random.seed(42) + x1 = np.random.randn(100) + x2 = 0.9 * x1 + 0.1 * np.random.randn(100) + X = np.column_stack([x1, x2]) + + mu, sigma2 = estimateGaussian(X) + p = multivariateGaussian(X, mu, sigma2) + + assert np.all(p > 0) + assert np.all(p <= 1) + + def test_zero_variance(self): + """测试零方差特征""" + X = np.column_stack([ + np.random.randn(50), + np.ones(50) # 常数特征 + ]) + + mu, sigma2 = estimateGaussian(X) + assert sigma2[1] == 0 + + # 零方差会导致概率计算中的除零 + # 实际代码使用对角协方差,应能处理 + p = multivariateGaussian(X, mu, sigma2) + # 对于常数特征,概率可能为0或无穷 + + +class TestVisualization: + """可视化函数测试类""" + + def test_display_2d_data(self): + """测试2D数据显示函数""" + X = np.random.randn(50, 2) + try: + plt = display_2d_data(X, 'bx') + assert plt is not None + except Exception as e: + pytest.skip(f"Plotting failed: {e}") + + +class TestNumericalStability: + """数值稳定性测试类""" + + def test_very_small_probabilities(self): + """测试极小概率""" + X = np.array([[10, 10]]) + mu = np.array([0, 0]) + sigma2 = np.array([1, 1]) + + p = multivariateGaussian(X, mu, sigma2) + # 远离均值时概率应很小 + assert p[0] < 1e-10 + + def test_very_large_values(self): + """测试极大值""" + X = np.array([[1000, 1000]]) + mu = np.array([0, 0]) + sigma2 = np.array([1, 1]) + + p = multivariateGaussian(X, mu, sigma2) + # 不应出现NaN或Inf + assert not np.isnan(p[0]) + assert not np.isinf(p[0]) + + def test_nearly_singular_covariance(self): + """测试接近奇异的协方差""" + # 高度相关的特征 + x1 = np.random.randn(100) + x2 = x1 + 1e-10 * np.random.randn(100) + X = np.column_stack([x1, x2]) + + mu, sigma2 = estimateGaussian(X) + # 不应报错 + p = multivariateGaussian(X, mu, sigma2) + assert np.all(np.isfinite(p)) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_kmeans.py b/tests/test_kmeans.py new file mode 100644 index 0000000..dbe6aaa --- /dev/null +++ b/tests/test_kmeans.py @@ -0,0 +1,334 @@ +# -*- coding: utf-8 -*- +""" +K-Means聚类算法测试模块 +测试内容: +- 数据加载功能 +- 最近类中心查找 +- 类中心计算 +- K-Means迭代过程 +- 类中心初始化 +- 端到端集成测试 +- 与 scikit-learn 对比 +""" +import pytest +import numpy as np +import os +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, PROJECT_ROOT) + +# 动态导入模块 +import importlib.util +spec = importlib.util.spec_from_file_location("KMeans", + os.path.join(PROJECT_ROOT, "K-Means", "K-Menas.py")) +KMeans = importlib.util.module_from_spec(spec) +spec.loader.exec_module(KMeans) + +findClosestCentroids = KMeans.findClosestCentroids +computerCentroids = KMeans.computerCentroids +runKMeans = KMeans.runKMeans +kMeansInitCentroids = KMeans.kMeansInitCentroids + +from scipy import io as spio + + +try: + from sklearn.cluster import KMeans as SklearnKMeans + SKLEARN_AVAILABLE = True +except ImportError: + SKLEARN_AVAILABLE = False + + +class TestDataLoading: + """数据加载测试类""" + + def test_load_mat_data(self, kmeans_data_path): + """测试 mat 文件加载""" + if os.path.exists(kmeans_data_path): + data = spio.loadmat(kmeans_data_path) + assert isinstance(data, dict) + assert 'X' in data + X = data['X'] + assert isinstance(X, np.ndarray) + assert X.ndim == 2 + + +class TestFindClosestCentroids: + """最近类中心查找测试类""" + + def test_find_closest_centroids_shape(self, sample_clustering_data): + """测试输出形状""" + X, K, centroids = sample_clustering_data + idx = findClosestCentroids(X, centroids) + assert idx.shape == (X.shape[0],) + + def test_find_closest_centroids_range(self, sample_clustering_data): + """测试输出范围""" + X, K, centroids = sample_clustering_data + idx = findClosestCentroids(X, centroids) + assert np.all(idx >= 0) + assert np.all(idx < K) + + def test_find_closest_centroids_exact(self): + """测试精确查找""" + X = np.array([[0, 0], [10, 10], [0, 10]]) + centroids = np.array([[0, 0], [10, 10]]) + idx = findClosestCentroids(X, centroids) + # 第一个点应属于类0,第二个属于类1,第三个与类0更近 + expected = np.array([0, 1, 0]) + np.testing.assert_array_equal(idx, expected) + + def test_find_closest_centroids_single_point(self): + """测试单点""" + X = np.array([[5, 5]]) + centroids = np.array([[0, 0], [10, 10]]) + idx = findClosestCentroids(X, centroids) + # 与两个类中心等距,可以属于任意一类 + assert idx[0] in [0, 1] + + +class TestComputeCentroids: + """类中心计算测试类""" + + def test_compute_centroids_shape(self, sample_clustering_data): + """测试输出形状""" + X, K, initial_centroids = sample_clustering_data + # 先分配每个点到最近的类中心 + idx = findClosestCentroids(X, initial_centroids) + centroids = computerCentroids(X, idx, K) + assert centroids.shape == (K, X.shape[1]) + + def test_compute_centroids_exact(self): + """测试精确计算""" + X = np.array([[0, 0], [0, 1], [10, 10], [10, 11]]) + idx = np.array([0, 0, 1, 1]) + K = 2 + centroids = computerCentroids(X, idx, K) + # 类0中心应为 [0, 0.5],类1中心应为 [10, 10.5] + expected = np.array([[0, 0.5], [10, 10.5]]) + np.testing.assert_array_almost_equal(centroids, expected) + + def test_compute_centroids_empty_cluster(self): + """测试空类情况""" + X = np.array([[0, 0], [1, 1]]) + idx = np.array([0, 0]) + K = 2 + # 类1没有样本,中心应为 [0, 0] + centroids = computerCentroids(X, idx, K) + assert centroids.shape == (2, 2) + # 类0中心应为 [0.5, 0.5] + np.testing.assert_array_almost_equal(centroids[0], [0.5, 0.5]) + + +class TestKMeansInitCentroids: + """类中心初始化测试类""" + + def test_init_centroids_shape(self, sample_clustering_data): + """测试初始化形状""" + X, K, _ = sample_clustering_data + centroids = kMeansInitCentroids(X, K) + assert centroids.shape == (K, X.shape[1]) + + def test_init_centroids_from_data(self, sample_clustering_data): + """测试初始化点来自数据""" + X, K, _ = sample_clustering_data + centroids = kMeansInitCentroids(X, K) + # 每个中心应存在于原始数据中 + for centroid in centroids: + matches = np.all(np.isclose(X, centroid), axis=1) + assert np.any(matches) + + def test_init_centroids_randomness(self, sample_clustering_data): + """测试初始化随机性""" + X, K, _ = sample_clustering_data + centroids1 = kMeansInitCentroids(X, K) + centroids2 = kMeansInitCentroids(X, K) + # 两次初始化很可能不同 + assert not np.allclose(centroids1, centroids2) + + +class TestRunKMeans: + """K-Means运行测试类""" + + def test_run_kmeans_shape(self, sample_clustering_data): + """测试输出形状""" + X, K, initial_centroids = sample_clustering_data + max_iters = 10 + centroids, idx = runKMeans(X, initial_centroids, max_iters, False) + assert centroids.shape == (K, X.shape[1]) + assert idx.shape == (X.shape[0],) + + def test_run_kmeans_convergence(self, sample_clustering_data): + """测试收敛性""" + X, K, _ = sample_clustering_data + initial_centroids = kMeansInitCentroids(X, K) + max_iters = 100 + centroids, idx = runKMeans(X, initial_centroids, max_iters, False) + # 验证每个点都被分配 + assert len(idx) == X.shape[0] + assert np.all(idx >= 0) + assert np.all(idx < K) + + def test_run_kmeans_improvement(self, sample_clustering_data): + """测试聚类改进""" + X, K, _ = sample_clustering_data + initial_centroids = kMeansInitCentroids(X, K) + + # 计算初始分配的距离平方和 + idx_initial = findClosestCentroids(X, initial_centroids) + initial_cost = np.sum([ + np.sum((X[i] - initial_centroids[idx_initial[i]]) ** 2) + for i in range(X.shape[0]) + ]) + + # 运行K-Means + centroids_final, idx_final = runKMeans(X, initial_centroids, 50, False) + final_cost = np.sum([ + np.sum((X[i] - centroids_final[idx_final[i]]) ** 2) + for i in range(X.shape[0]) + ]) + + # 最终代价应小于等于初始代价 + assert final_cost <= initial_cost * 1.01 # 允许1%误差 + + +class TestIntegration: + """集成测试类""" + + def test_kmeans_end_to_end(self, sample_clustering_data): + """端到端集成测试""" + X, K, _ = sample_clustering_data + + # 初始化 + initial_centroids = kMeansInitCentroids(X, K) + + # 运行K-Means + centroids, idx = runKMeans(X, initial_centroids, 100, False) + + # 验证结果 + assert centroids.shape == (K, X.shape[1]) + assert idx.shape == (X.shape[0],) + assert np.all(idx >= 0) + assert np.all(idx < K) + + # 计算最终代价 + final_cost = np.sum([ + np.sum((X[i] - centroids[idx[i]]) ** 2) + for i in range(X.shape[0]) + ]) + assert final_cost >= 0 + + def test_kmeans_on_well_separated_clusters(self): + """测试在明显分离的数据上的聚类""" + np.random.seed(42) + # 创建三个明显分离的簇 + cluster1 = np.random.randn(30, 2) + np.array([0, 0]) + cluster2 = np.random.randn(30, 2) + np.array([10, 0]) + cluster3 = np.random.randn(30, 2) + np.array([5, 10]) + X = np.vstack([cluster1, cluster2, cluster3]) + + K = 3 + initial_centroids = kMeansInitCentroids(X, K) + centroids, idx = runKMeans(X, initial_centroids, 100, False) + + # 每个簇应主要包含来自同一真实簇的点 + for k in range(K): + cluster_points = idx[k*30:(k+1)*30] + # 找到该段中最常见的类别 + unique, counts = np.unique(cluster_points, return_counts=True) + most_common_count = np.max(counts) + # 至少60%的点应属于同一类别 + assert most_common_count >= 18 + + +class TestEdgeCases: + """边界情况测试类""" + + def test_single_cluster(self): + """测试K=1""" + X = np.random.randn(50, 2) + K = 1 + initial_centroids = kMeansInitCentroids(X, K) + centroids, idx = runKMeans(X, initial_centroids, 10, False) + assert centroids.shape == (1, 2) + assert np.all(idx == 0) + + def test_more_clusters_than_points(self): + """测试K大于样本数""" + X = np.random.randn(5, 2) + K = 10 + initial_centroids = kMeansInitCentroids(X, K) + centroids, idx = runKMeans(X, initial_centroids, 10, False) + # 只有部分类中心会被使用 + unique_idx = np.unique(idx) + assert len(unique_idx) <= X.shape[0] + + def test_high_dimensional_data(self): + """测试高维数据""" + np.random.seed(42) + X = np.random.randn(100, 50) + K = 3 + initial_centroids = kMeansInitCentroids(X, K) + centroids, idx = runKMeans(X, initial_centroids, 20, False) + assert centroids.shape == (K, 50) + assert idx.shape == (100,) + + def test_zero_iterations(self): + """测试0次迭代""" + X, K, initial_centroids = sample_clustering_data + centroids, idx = runKMeans(X, initial_centroids, 0, False) + # 类中心应保持不变 + np.testing.assert_array_equal(centroids, initial_centroids) + + +class TestSklearnComparison: + """与 scikit-learn 对比测试类""" + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_compare_with_sklearn(self, sample_clustering_data): + """与 sklearn K-Means 对比""" + X, K, _ = sample_clustering_data + + # 自定义实现 + initial_centroids = kMeansInitCentroids(X, K) + centroids_custom, idx_custom = runKMeans(X, initial_centroids, 100, False) + cost_custom = np.sum([ + np.sum((X[i] - centroids_custom[idx_custom[i]]) ** 2) + for i in range(X.shape[0]) + ]) + + # sklearn 实现 + kmeans = SklearnKMeans(n_clusters=K, random_state=42, n_init=10) + kmeans.fit(X) + cost_sklearn = kmeans.inertia_ + + # 两者代价应相近(允许一定差异) + ratio = cost_custom / cost_sklearn if cost_sklearn > 0 else 1 + assert 0.5 <= ratio <= 2.0 + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_predict_consistency(self, sample_clustering_data): + """测试预测一致性""" + X, K, _ = sample_clustering_data + + # 自定义实现 + initial_centroids = kMeansInitCentroids(X, K) + centroids_custom, _ = runKMeans(X, initial_centroids, 50, False) + + # sklearn 实现 + kmeans = SklearnKMeans(n_clusters=K, random_state=42, n_init=10) + kmeans.fit(X) + + # 比较类中心(可能需要重新排序) + # 由于K-Means可能收敛到不同局部最优,我们只比较代价 + cost_custom = np.sum(np.min([ + np.sum((X - c) ** 2, axis=1) for c in centroids_custom + ], axis=0)) + + ratio = cost_custom / kmeans.inertia_ if kmeans.inertia_ > 0 else 1 + assert 0.5 <= ratio <= 2.0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_linear_regression.py b/tests/test_linear_regression.py new file mode 100644 index 0000000..399603b --- /dev/null +++ b/tests/test_linear_regression.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +""" +线性回归算法测试模块 +测试内容: +- 数据加载功能 +- 特征归一化 +- 代价函数计算 +- 梯度下降算法 +- 端到端集成测试 +- 与 scikit-learn 对比 +""" +import pytest +import numpy as np +import os +import sys + +# 添加 LinearRegression 目录到路径 +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(PROJECT_ROOT, "LinearRegression")) + +from LinearRegression import ( + loadtxtAndcsv_data, + loadnpy_data, + featureNormaliza, + computerCost, + gradientDescent, + linearRegression +) + + +class TestDataLoading: + """数据加载测试类""" + + def test_loadtxt_and_csv_data(self, linear_regression_data_path): + """测试 txt 和 csv 数据加载功能""" + if os.path.exists(linear_regression_data_path): + data = loadtxtAndcsv_data(linear_regression_data_path, ",", np.float64) + assert isinstance(data, np.ndarray) + assert data.ndim == 2 + assert data.shape[0] > 0 + assert data.shape[1] >= 2 + + def test_loadtxt_nonexistent_file(self): + """测试加载不存在的文件""" + with pytest.raises((FileNotFoundError, OSError)): + loadtxtAndcsv_data("nonexistent_file.txt", ",", np.float64) + + def test_loadnpy_data(self): + """测试 npy 数据加载功能""" + npy_path = os.path.join(PROJECT_ROOT, "LinearRegression", "data.npy") + if os.path.exists(npy_path): + data = loadnpy_data(npy_path) + assert isinstance(data, np.ndarray) + + +class TestFeatureNormalization: + """特征归一化测试类""" + + def test_feature_normaliza_shape(self, sample_regression_data): + """测试归一化后数据形状保持不变""" + X, _, _ = sample_regression_data + X_norm, mu, sigma = featureNormaliza(X) + assert X_norm.shape == X.shape + assert mu.shape == (X.shape[1],) + assert sigma.shape == (X.shape[1],) + + def test_feature_normaliza_zero_mean_unit_std(self, sample_regression_data): + """测试归一化后均值为0,标准差为1""" + X, _, _ = sample_regression_data + X_norm, mu, sigma = featureNormaliza(X) + # 允许一定的数值误差 + np.testing.assert_array_almost_equal(np.mean(X_norm, axis=0), + np.zeros(X.shape[1]), decimal=10) + np.testing.assert_array_almost_equal(np.std(X_norm, axis=0), + np.ones(X.shape[1]), decimal=10) + + def test_feature_normaliza_single_feature(self): + """测试单特征归一化""" + X = np.array([[1], [2], [3], [4], [5]], dtype=np.float64) + X_norm, mu, sigma = featureNormaliza(X) + assert X_norm.shape == X.shape + np.testing.assert_almost_equal(np.mean(X_norm), 0, decimal=10) + + def test_feature_normaliza_constant_feature(self): + """测试常数特征归一化(标准差为0的情况)""" + X = np.array([[1, 5], [2, 5], [3, 5]], dtype=np.float64) + X_norm, mu, sigma = featureNormaliza(X) + # 常数特征归一化后应为 NaN 或 0 + assert X_norm.shape == X.shape + + +class TestCostFunction: + """代价函数测试类""" + + def test_computer_cost_shape(self, sample_regression_data): + """测试代价函数返回值类型""" + X, y, true_theta = sample_regression_data + m = len(y) + X = np.hstack((np.ones((m, 1)), X)) + theta = np.zeros((X.shape[1], 1)) + y = y.reshape(-1, 1) + J = computerCost(X, y, theta) + assert isinstance(J, (float, np.floating, np.ndarray)) + + def test_computer_cost_zero_theta(self, sample_regression_data): + """测试 theta 为零时的代价""" + X, y, _ = sample_regression_data + m = len(y) + X = np.hstack((np.ones((m, 1)), X)) + theta = np.zeros((X.shape[1], 1)) + y = y.reshape(-1, 1) + J = computerCost(X, y, theta) + # 代价应为正值 + assert J >= 0 + + def test_computer_cost_perfect_fit(self): + """测试完美拟合时的代价""" + X = np.array([[1, 2], [1, 3], [1, 4]], dtype=np.float64) + y = np.array([[5], [7], [9]], dtype=np.float64) + theta = np.array([[1], [2]], dtype=np.float64) + J = computerCost(X, y, theta) + # 完美拟合时代价应接近0 + np.testing.assert_almost_equal(J, 0, decimal=5) + + +class TestGradientDescent: + """梯度下降算法测试类""" + + def test_gradient_descent_convergence(self, sample_regression_data): + """测试梯度下降是否收敛""" + X, y, _ = sample_regression_data + m = len(y) + X_norm, mu, sigma = featureNormaliza(X) + X_norm = np.hstack((np.ones((m, 1)), X_norm)) + theta = np.zeros((X_norm.shape[1], 1)) + y = y.reshape(-1, 1) + + alpha = 0.01 + num_iters = 100 + theta_final, J_history = gradientDescent(X_norm, y, theta, alpha, num_iters) + + assert theta_final.shape == theta.shape + assert len(J_history) == num_iters + # 代价应随迭代递减 + assert J_history[-1] <= J_history[0] + + def test_gradient_descent_decreasing_cost(self, sample_regression_data): + """测试代价函数单调递减""" + X, y, _ = sample_regression_data + m = len(y) + X_norm, mu, sigma = featureNormaliza(X) + X_norm = np.hstack((np.ones((m, 1)), X_norm)) + theta = np.zeros((X_norm.shape[1], 1)) + y = y.reshape(-1, 1) + + alpha = 0.01 + num_iters = 50 + _, J_history = gradientDescent(X_norm, y, theta, alpha, num_iters) + + # 检查代价是否总体递减(允许微小波动) + for i in range(1, len(J_history)): + assert J_history[i] <= J_history[i-1] * 1.01 # 允许1%的误差 + + +class TestIntegration: + """集成测试类""" + + def test_linear_regression_end_to_end(self, sample_regression_data): + """端到端集成测试""" + X, y, true_theta = sample_regression_data + m = len(y) + + # 数据归一化 + X_norm, mu, sigma = featureNormaliza(X) + X_norm = np.hstack((np.ones((m, 1)), X_norm)) + y = y.reshape(-1, 1) + + # 训练模型 + theta = np.zeros((X_norm.shape[1], 1)) + alpha = 0.01 + num_iters = 200 + theta_final, J_history = gradientDescent(X_norm, y, theta, alpha, num_iters) + + # 验证结果 + assert theta_final.shape == (X_norm.shape[1], 1) + assert J_history[-1] < J_history[0] + + # 预测验证 + predictions = np.dot(X_norm, theta_final) + assert predictions.shape == y.shape + + def test_prediction_accuracy(self): + """测试预测准确性""" + # 创建简单线性数据 y = 2 + 3*x + X = np.array([[1, 1], [1, 2], [1, 3], [1, 4], [1, 5]], dtype=np.float64) + y = np.array([[5], [8], [11], [14], [17]], dtype=np.float64) + + theta = np.zeros((2, 1)) + alpha = 0.1 + num_iters = 1000 + theta_final, _ = gradientDescent(X, y, theta, alpha, num_iters) + + # 验证学习到的参数接近真实值 [2, 3] + np.testing.assert_array_almost_equal( + theta_final.flatten(), [2, 3], decimal=1 + ) + + +class TestEdgeCases: + """边界情况测试类""" + + def test_empty_data(self): + """测试空数据""" + X = np.array([]).reshape(0, 2) + with pytest.raises((IndexError, ValueError)): + featureNormaliza(X) + + def test_single_sample(self): + """测试单样本数据""" + X = np.array([[1, 2]], dtype=np.float64) + X_norm, mu, sigma = featureNormaliza(X) + # 单样本归一化后应为0(因为等于均值) + np.testing.assert_array_almost_equal(X_norm[0], [0, 0]) + + def test_large_values(self): + """测试大数值数据""" + X = np.array([[1000, 2000], [2000, 4000], [3000, 6000]], dtype=np.float64) + X_norm, mu, sigma = featureNormaliza(X) + # 归一化后应在合理范围内 + assert np.all(np.abs(X_norm) < 10) + + +class TestSklearnComparison: + """与 scikit-learn 对比测试类""" + + def test_compare_with_sklearn(self, sample_regression_data): + """与 sklearn 线性回归结果对比""" + try: + from sklearn.linear_model import LinearRegression + from sklearn.preprocessing import StandardScaler + except ImportError: + pytest.skip("scikit-learn not installed") + + X, y, _ = sample_regression_data + + # 自定义实现 + m = len(y) + X_norm_custom, mu, sigma = featureNormaliza(X) + X_norm_custom = np.hstack((np.ones((m, 1)), X_norm_custom)) + theta = np.zeros((X_norm_custom.shape[1], 1)) + theta_custom, _ = gradientDescent(X_norm_custom, y.reshape(-1, 1), + theta, 0.01, 500) + + # sklearn 实现 + scaler = StandardScaler() + X_sklearn = scaler.fit_transform(X) + model = LinearRegression() + model.fit(X_sklearn, y) + + # 比较预测结果(允许一定误差) + predictions_custom = np.dot(X_norm_custom, theta_custom) + predictions_sklearn = model.predict(X_sklearn) + + correlation = np.corrcoef(predictions_custom.flatten(), + predictions_sklearn)[0, 1] + assert correlation > 0.95 # 相关性应很高 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_logistic_regression.py b/tests/test_logistic_regression.py new file mode 100644 index 0000000..06dcd88 --- /dev/null +++ b/tests/test_logistic_regression.py @@ -0,0 +1,364 @@ +# -*- coding: utf-8 -*- +""" +逻辑回归算法测试模块 +测试内容: +- 数据加载功能 +- Sigmoid 函数 +- 特征映射 +- 代价函数与梯度计算 +- 预测函数 +- 正则化 +- 端到端集成测试 +- 与 scikit-learn 对比 +""" +import pytest +import numpy as np +import os +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(PROJECT_ROOT, "LogisticRegression")) + +from LogisticRegression import ( + loadtxtAndcsv_data, + loadnpy_data, + sigmoid, + mapFeature, + costFunction, + gradient, + predict +) + + +class TestSigmoidFunction: + """Sigmoid 函数测试类""" + + def test_sigmoid_zero(self): + """测试 sigmoid(0) = 0.5""" + z = np.array([0]) + result = sigmoid(z) + np.testing.assert_almost_equal(result[0], 0.5, decimal=5) + + def test_sigmoid_positive_large(self): + """测试 sigmoid 在大正数时接近 1""" + z = np.array([10, 100]) + result = sigmoid(z) + assert np.all(result > 0.99) + assert np.all(result <= 1.0) + + def test_sigmoid_negative_large(self): + """测试 sigmoid 在大负数时接近 0""" + z = np.array([-10, -100]) + result = sigmoid(z) + assert np.all(result < 0.01) + assert np.all(result >= 0.0) + + def test_sigmoid_range(self): + """测试 sigmoid 输出范围在 (0, 1)""" + z = np.linspace(-10, 10, 100) + result = sigmoid(z) + assert np.all(result > 0) + assert np.all(result < 1) + + def test_sigmoid_shape(self): + """测试 sigmoid 输出形状与输入一致""" + z = np.array([[1, 2], [3, 4]]) + result = sigmoid(z) + assert result.shape == z.shape + + +class TestFeatureMapping: + """特征映射测试类""" + + def test_map_feature_degree_2(self): + """测试 degree=2 的特征映射""" + X1 = np.array([1, 2, 3]) + X2 = np.array([4, 5, 6]) + result = mapFeature(X1, X2) + # degree=2 时应有 1 + 2 + 3 = 6 个特征 + # 1, x1, x2, x1^2, x1*x2, x2^2 + assert result.shape == (3, 6) + # 第一列应为 1 + np.testing.assert_array_equal(result[:, 0], [1, 1, 1]) + + def test_map_feature_output_values(self): + """测试特征映射输出值正确性""" + X1 = np.array([1]) + X2 = np.array([2]) + result = mapFeature(X1, X2) + # 期望: [1, 1, 2, 1, 2, 4] + expected = np.array([[1, 1, 2, 1, 2, 4]]) + np.testing.assert_array_equal(result, expected) + + +class TestCostFunction: + """代价函数测试类""" + + def test_cost_function_initial_value(self, sample_classification_data): + """测试初始 theta=0 时的代价约为 0.693""" + X, y = sample_classification_data + # 添加偏置项 + X = np.hstack((np.ones((X.shape[0], 1)), X)) + initial_theta = np.zeros(X.shape[1]) + + J = costFunction(initial_theta, X, y, 0) + # 初始代价应约为 0.693 (ln(2)) + np.testing.assert_almost_equal(J, 0.693, decimal=2) + + def test_cost_function_positive(self, sample_classification_data): + """测试代价函数始终为正""" + X, y = sample_classification_data + X = np.hstack((np.ones((X.shape[0], 1)), X)) + + np.random.seed(42) + for _ in range(5): + theta = np.random.randn(X.shape[1]) + J = costFunction(theta, X, y, 0) + assert J >= 0 + + def test_cost_function_with_regularization(self, sample_classification_data): + """测试带正则化的代价函数""" + X, y = sample_classification_data + X = np.hstack((np.ones((X.shape[0], 1)), X)) + theta = np.ones(X.shape[1]) + + J_no_reg = costFunction(theta, X, y, 0) + J_with_reg = costFunction(theta, X, y, 1.0) + + # 正则化后的代价应更大 + assert J_with_reg >= J_no_reg + + +class TestGradient: + """梯度计算测试类""" + + def test_gradient_shape(self, sample_classification_data): + """测试梯度形状与 theta 一致""" + X, y = sample_classification_data + X = np.hstack((np.ones((X.shape[0], 1)), X)) + theta = np.zeros(X.shape[1]) + + grad = gradient(theta, X, y, 0) + assert grad.shape == theta.shape + + def test_gradient_zero_theta(self, sample_classification_data): + """测试 theta=0 时的梯度""" + X, y = sample_classification_data + X = np.hstack((np.ones((X.shape[0], 1)), X)) + theta = np.zeros(X.shape[1]) + + grad = gradient(theta, X, y, 0) + # 梯度不应全为零 + assert not np.allclose(grad, 0) + + def test_gradient_numerical_check(self, sample_classification_data): + """数值梯度检查""" + X, y = sample_classification_data + X = np.hstack((np.ones((X.shape[0], 1)), X)) + theta = np.random.randn(X.shape[1]) * 0.1 + + grad = gradient(theta, X, y, 0) + + # 数值梯度 + epsilon = 1e-4 + num_grad = np.zeros_like(theta) + for i in range(len(theta)): + theta_plus = theta.copy() + theta_minus = theta.copy() + theta_plus[i] += epsilon + theta_minus[i] -= epsilon + num_grad[i] = (costFunction(theta_plus, X, y, 0) - + costFunction(theta_minus, X, y, 0)) / (2 * epsilon) + + np.testing.assert_array_almost_equal(grad, num_grad, decimal=4) + + +class TestPrediction: + """预测函数测试类""" + + def test_predict_shape(self, sample_classification_data): + """测试预测输出形状""" + X, y = sample_classification_data + X_mapped = mapFeature(X[:, 0], X[:, 1]) + theta = np.zeros(X_mapped.shape[1]) + + p = predict(X_mapped, theta) + assert p.shape == (X.shape[0],) + + def test_predict_binary_output(self, sample_classification_data): + """测试预测输出为 0 或 1""" + X, y = sample_classification_data + X_mapped = mapFeature(X[:, 0], X[:, 1]) + np.random.seed(42) + theta = np.random.randn(X_mapped.shape[1]) + + p = predict(X_mapped, theta) + assert np.all(np.isin(p, [0, 1])) + + def test_predict_perfect_separation(self): + """测试完美分离情况""" + # 创建线性可分数据 + X = np.array([[30, 30], [40, 40], [90, 90], [100, 100]]) + y = np.array([0, 0, 1, 1]) + X_mapped = mapFeature(X[:, 0], X[:, 1]) + # 使用能完美分类的 theta + theta = np.zeros(X_mapped.shape[1]) + theta[1] = 1 # x1 的权重 + theta[2] = 1 # x2 的权重 + + p = predict(X_mapped, theta) + # 至少应该能正确分类大部分 + accuracy = np.mean(p == y) + assert accuracy >= 0.5 + + +class TestDataLoading: + """数据加载测试类""" + + def test_loadtxt_data(self, logistic_regression_data_path): + """测试数据文件加载""" + if os.path.exists(logistic_regression_data_path): + data = loadtxtAndcsv_data(logistic_regression_data_path, ",", np.float64) + assert isinstance(data, np.ndarray) + assert data.ndim == 2 + assert data.shape[1] >= 3 # 至少2个特征+1个标签 + + def test_load_npy_data(self): + """测试 npy 文件加载""" + npy_path = os.path.join(PROJECT_ROOT, "LogisticRegression", "data1.npy") + if os.path.exists(npy_path): + data = loadnpy_data(npy_path) + assert isinstance(data, np.ndarray) + + +class TestIntegration: + """集成测试类""" + + def test_logistic_regression_workflow(self, sample_classification_data): + """端到端工作流测试""" + X, y = sample_classification_data + + # 特征映射 + X_mapped = mapFeature(X[:, 0], X[:, 1]) + + # 初始化参数 + initial_theta = np.zeros(X_mapped.shape[1]) + + # 计算初始代价 + initial_cost = costFunction(initial_theta, X_mapped, y, 0.1) + + # 使用 scipy 优化 + from scipy import optimize + result = optimize.fmin_bfgs(costFunction, initial_theta, + fprime=gradient, + args=(X_mapped, y, 0.1), + disp=False) + + # 预测 + p = predict(X_mapped, result) + accuracy = np.mean(p == y) + + # 准确率应高于随机猜测 + assert accuracy > 0.5 + + def test_regularization_effect(self, sample_classification_data): + """测试正则化效果""" + X, y = sample_classification_data + X_mapped = mapFeature(X[:, 0], X[:, 1]) + initial_theta = np.zeros(X_mapped.shape[1]) + + from scipy import optimize + + # 无正则化 + result_no_reg = optimize.fmin_bfgs(costFunction, initial_theta, + fprime=gradient, + args=(X_mapped, y, 0), + disp=False) + + # 有正则化 + result_with_reg = optimize.fmin_bfgs(costFunction, initial_theta, + fprime=gradient, + args=(X_mapped, y, 10), + disp=False) + + # 正则化后的参数范数应更小 + assert np.linalg.norm(result_with_reg) <= np.linalg.norm(result_no_reg) * 1.5 + + +class TestEdgeCases: + """边界情况测试类""" + + def test_empty_data(self): + """测试空数据""" + X = np.array([]).reshape(0, 2) + y = np.array([]) + if X.shape[0] > 0: # 避免空数组测试 + X_mapped = mapFeature(X[:, 0], X[:, 1]) + theta = np.zeros(X_mapped.shape[1]) + with pytest.raises((ValueError, IndexError)): + costFunction(theta, X_mapped, y, 0) + + def test_single_sample(self): + """测试单样本""" + X = np.array([[1, 2]]) + y = np.array([1]) + X_mapped = mapFeature(X[:, 0], X[:, 1]) + theta = np.zeros(X_mapped.shape[1]) + + # 单样本不应报错 + J = costFunction(theta, X_mapped, y, 0) + assert isinstance(J, (float, np.floating)) + + def test_all_same_class(self): + """测试所有样本属于同一类""" + X = np.random.randn(10, 2) + y = np.ones(10) + X_mapped = mapFeature(X[:, 0], X[:, 1]) + theta = np.zeros(X_mapped.shape[1]) + + # 不应报错 + J = costFunction(theta, X_mapped, y, 0) + assert J >= 0 + + +class TestSklearnComparison: + """与 scikit-learn 对比测试类""" + + def test_compare_with_sklearn(self, sample_classification_data): + """与 sklearn 逻辑回归对比""" + try: + from sklearn.linear_model import LogisticRegression + from sklearn.preprocessing import PolynomialFeatures + except ImportError: + pytest.skip("scikit-learn not installed") + + X, y = sample_classification_data + + # 自定义实现 + X_mapped = mapFeature(X[:, 0], X[:, 1]) + initial_theta = np.zeros(X_mapped.shape[1]) + + from scipy import optimize + result_custom = optimize.fmin_bfgs(costFunction, initial_theta, + fprime=gradient, + args=(X_mapped, y, 0.1), + disp=False) + p_custom = predict(X_mapped, result_custom) + + # sklearn 实现 + poly = PolynomialFeatures(degree=2) + X_poly = poly.fit_transform(X) + model = LogisticRegression(max_iter=1000, C=10) + model.fit(X_poly, y) + p_sklearn = model.predict(X_poly) + + # 比较准确率 + acc_custom = np.mean(p_custom == y) + acc_sklearn = np.mean(p_sklearn == y) + + # 两者准确率差异不应太大 + assert abs(acc_custom - acc_sklearn) < 0.3 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_neural_network.py b/tests/test_neural_network.py new file mode 100644 index 0000000..01ff321 --- /dev/null +++ b/tests/test_neural_network.py @@ -0,0 +1,405 @@ +# -*- coding: utf-8 -*- +""" +BP神经网络算法测试模块 +测试内容: +- 数据加载功能 +- Sigmoid 函数及其导数 +- 权重随机初始化 +- 代价函数计算 +- 反向传播梯度计算 +- 梯度检查 +- 预测函数 +- 端到端集成测试 +""" +import pytest +import numpy as np +import os +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(PROJECT_ROOT, "NeuralNetwok")) + +from NeuralNetwork import ( + loadmat_data, + sigmoid, + sigmoidGradient, + randInitializeWeights, + nnCostFunction, + nnGradient, + predict, + checkGradient, + debugInitializeWeights +) + + +class TestSigmoidFunctions: + """Sigmoid 函数测试类""" + + def test_sigmoid_zero(self): + """测试 sigmoid(0) = 0.5""" + z = np.array([0]) + result = sigmoid(z) + np.testing.assert_almost_equal(result[0], 0.5, decimal=5) + + def test_sigmoid_range(self): + """测试 sigmoid 输出范围""" + z = np.linspace(-10, 10, 100) + result = sigmoid(z) + assert np.all(result > 0) + assert np.all(result < 1) + + def test_sigmoid_gradient(self): + """测试 sigmoid 导数""" + z = np.array([0]) + result = sigmoidGradient(z) + # sigmoid'(0) = sigmoid(0) * (1 - sigmoid(0)) = 0.25 + np.testing.assert_almost_equal(result[0], 0.25, decimal=5) + + def test_sigmoid_gradient_formula(self): + """测试 sigmoid 导数公式正确性""" + z = np.array([1, 2, 3]) + g = sigmoidGradient(z) + s = sigmoid(z) + expected = s * (1 - s) + np.testing.assert_array_almost_equal(g, expected) + + +class TestWeightInitialization: + """权重初始化测试类""" + + def test_rand_initialize_weights_shape(self): + """测试权重初始化形状""" + L_in = 3 + L_out = 5 + W = randInitializeWeights(L_in, L_out) + assert W.shape == (L_out, L_in + 1) # +1 for bias + + def test_rand_initialize_weights_range(self): + """测试权重初始化范围""" + L_in = 10 + L_out = 10 + W = randInitializeWeights(L_in, L_out) + epsilon = (6.0 / (L_out + L_in)) ** 0.5 + assert np.all(W >= -epsilon) + assert np.all(W <= epsilon) + + def test_rand_initialize_weights_randomness(self): + """测试权重随机性""" + L_in = 5 + L_out = 5 + W1 = randInitializeWeights(L_in, L_out) + W2 = randInitializeWeights(L_in, L_out) + # 两次初始化应该不同 + assert not np.allclose(W1, W2) + + def test_debug_initialize_weights(self): + """测试调试权重初始化""" + fan_in = 3 + fan_out = 5 + W = debugInitializeWeights(fan_in, fan_out) + assert W.shape == (fan_out, fan_in + 1) + # 调试权重使用 sin 函数,值应在 [-0.1, 0.1] + assert np.all(W >= -0.1) + assert np.all(W <= 0.1) + + +class TestCostFunction: + """代价函数测试类""" + + def test_nn_cost_function_shape(self): + """测试代价函数返回值""" + input_size = 3 + hidden_size = 5 + num_labels = 3 + m = 5 + + Theta1 = randInitializeWeights(input_size, hidden_size) + Theta2 = randInitializeWeights(hidden_size, num_labels) + nn_params = np.vstack((Theta1.reshape(-1, 1), Theta2.reshape(-1, 1))) + + X = np.random.rand(m, input_size) + y = np.random.randint(0, num_labels, m) + + J = nnCostFunction(nn_params, input_size, hidden_size, num_labels, X, y, 0) + assert isinstance(J, (float, np.floating)) + assert J >= 0 + + def test_nn_cost_function_positive(self): + """测试代价函数始终为正""" + input_size = 2 + hidden_size = 3 + num_labels = 2 + m = 10 + + Theta1 = randInitializeWeights(input_size, hidden_size) + Theta2 = randInitializeWeights(hidden_size, num_labels) + nn_params = np.vstack((Theta1.reshape(-1, 1), Theta2.reshape(-1, 1))) + + X = np.random.rand(m, input_size) + y = np.random.randint(0, num_labels, m) + + for lambda_val in [0, 0.1, 1, 10]: + J = nnCostFunction(nn_params, input_size, hidden_size, num_labels, X, y, lambda_val) + assert J >= 0 + + def test_nn_cost_function_regularization(self): + """测试正则化效果""" + input_size = 2 + hidden_size = 3 + num_labels = 2 + m = 10 + + Theta1 = randInitializeWeights(input_size, hidden_size) + Theta2 = randInitializeWeights(hidden_size, num_labels) + nn_params = np.vstack((Theta1.reshape(-1, 1), Theta2.reshape(-1, 1))) + + X = np.random.rand(m, input_size) + y = np.random.randint(0, num_labels, m) + + J_no_reg = nnCostFunction(nn_params, input_size, hidden_size, num_labels, X, y, 0) + J_with_reg = nnCostFunction(nn_params, input_size, hidden_size, num_labels, X, y, 1) + + assert J_with_reg >= J_no_reg + + +class TestGradient: + """梯度计算测试类""" + + def test_nn_gradient_shape(self): + """测试梯度形状""" + input_size = 3 + hidden_size = 5 + num_labels = 3 + m = 5 + + Theta1 = randInitializeWeights(input_size, hidden_size) + Theta2 = randInitializeWeights(hidden_size, num_labels) + nn_params = np.vstack((Theta1.reshape(-1, 1), Theta2.reshape(-1, 1))) + + X = np.random.rand(m, input_size) + y = np.random.randint(0, num_labels, m) + + grad = nnGradient(nn_params, input_size, hidden_size, num_labels, X, y, 0) + assert grad.shape == nn_params.shape + + def test_gradient_numerical_check(self): + """数值梯度检查""" + input_size = 3 + hidden_size = 5 + num_labels = 3 + m = 5 + + initial_Theta1 = debugInitializeWeights(input_size, hidden_size) + initial_Theta2 = debugInitializeWeights(hidden_size, num_labels) + X = debugInitializeWeights(input_size - 1, m) + y = np.transpose(np.mod(np.arange(1, m + 1), num_labels)) + y = y.reshape(-1, 1) + + nn_params = np.vstack((initial_Theta1.reshape(-1, 1), initial_Theta2.reshape(-1, 1))) + + grad = nnGradient(nn_params, input_size, hidden_size, num_labels, X, y, 0) + + # 数值梯度 + epsilon = 1e-4 + num_grad = np.zeros((nn_params.shape[0])) + step = np.zeros((nn_params.shape[0])) + + for i in range(nn_params.shape[0]): + step[i] = epsilon + loss1 = nnCostFunction(nn_params - step.reshape(-1, 1), input_size, hidden_size, num_labels, X, y, 0) + loss2 = nnCostFunction(nn_params + step.reshape(-1, 1), input_size, hidden_size, num_labels, X, y, 0) + num_grad[i] = (loss2 - loss1) / (2 * epsilon) + step[i] = 0 + + np.testing.assert_array_almost_equal(grad.flatten(), num_grad, decimal=4) + + +class TestPrediction: + """预测函数测试类""" + + def test_predict_shape(self): + """测试预测输出形状""" + input_size = 3 + hidden_size = 5 + num_labels = 3 + m = 10 + + Theta1 = randInitializeWeights(input_size, hidden_size) + Theta2 = randInitializeWeights(hidden_size, num_labels) + X = np.random.rand(m, input_size) + + p = predict(Theta1, Theta2, X) + assert p.shape == (m, 1) + + def test_predict_range(self): + """测试预测输出范围""" + input_size = 3 + hidden_size = 5 + num_labels = 4 + m = 10 + + Theta1 = randInitializeWeights(input_size, hidden_size) + Theta2 = randInitializeWeights(hidden_size, num_labels) + X = np.random.rand(m, input_size) + + p = predict(Theta1, Theta2, X) + assert np.all(p >= 0) + assert np.all(p < num_labels) + + def test_predict_deterministic(self): + """测试预测确定性""" + input_size = 3 + hidden_size = 5 + num_labels = 3 + m = 10 + + Theta1 = randInitializeWeights(input_size, hidden_size) + Theta2 = randInitializeWeights(hidden_size, num_labels) + X = np.random.rand(m, input_size) + + p1 = predict(Theta1, Theta2, X) + p2 = predict(Theta1, Theta2, X) + np.testing.assert_array_equal(p1, p2) + + +class TestDataLoading: + """数据加载测试类""" + + def test_load_mat_data(self, neural_network_data_path): + """测试 mat 文件加载""" + if os.path.exists(neural_network_data_path): + data = loadmat_data(neural_network_data_path) + assert isinstance(data, dict) + assert 'X' in data + assert 'y' in data + assert isinstance(data['X'], np.ndarray) + assert isinstance(data['y'], np.ndarray) + + +class TestIntegration: + """集成测试类""" + + def test_neural_network_training(self): + """神经网络训练集成测试""" + from scipy import optimize + + input_size = 2 + hidden_size = 5 + num_labels = 2 + m = 50 + + # 生成简单分类数据 + np.random.seed(42) + X = np.random.randn(m, input_size) + y = (X[:, 0] + X[:, 1] > 0).astype(int) + + # 初始化权重 + initial_Theta1 = randInitializeWeights(input_size, hidden_size) + initial_Theta2 = randInitializeWeights(hidden_size, num_labels) + initial_nn_params = np.vstack((initial_Theta1.reshape(-1, 1), initial_Theta2.reshape(-1, 1))) + + # 优化 + result = optimize.fmin_cg(nnCostFunction, initial_nn_params, + fprime=nnGradient, + args=(input_size, hidden_size, num_labels, X, y, 1), + maxiter=50, disp=False) + + # 提取权重 + length = result.shape[0] + Theta1 = result[0:hidden_size * (input_size + 1)].reshape(hidden_size, input_size + 1) + Theta2 = result[hidden_size * (input_size + 1):length].reshape(num_labels, hidden_size + 1) + + # 预测 + p = predict(Theta1, Theta2, X) + accuracy = np.mean(p.flatten() == y) + + # 准确率应高于随机猜测 + assert accuracy > 0.5 + + def test_xor_problem(self): + """测试 XOR 问题""" + from scipy import optimize + + # XOR 数据集 + X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) + y = np.array([0, 1, 1, 0]) + + input_size = 2 + hidden_size = 4 + num_labels = 2 + + initial_Theta1 = randInitializeWeights(input_size, hidden_size) + initial_Theta2 = randInitializeWeights(hidden_size, num_labels) + initial_nn_params = np.vstack((initial_Theta1.reshape(-1, 1), initial_Theta2.reshape(-1, 1))) + + result = optimize.fmin_cg(nnCostFunction, initial_nn_params, + fprime=nnGradient, + args=(input_size, hidden_size, num_labels, X, y, 0.1), + maxiter=100, disp=False) + + length = result.shape[0] + Theta1 = result[0:hidden_size * (input_size + 1)].reshape(hidden_size, input_size + 1) + Theta2 = result[hidden_size * (input_size + 1):length].reshape(num_labels, hidden_size + 1) + + p = predict(Theta1, Theta2, X) + accuracy = np.mean(p.flatten() == y) + + # XOR 问题应该能学到较高准确率 + assert accuracy >= 0.5 + + +class TestEdgeCases: + """边界情况测试类""" + + def test_single_sample(self): + """测试单样本""" + input_size = 2 + hidden_size = 3 + num_labels = 2 + + Theta1 = randInitializeWeights(input_size, hidden_size) + Theta2 = randInitializeWeights(hidden_size, num_labels) + X = np.random.rand(1, input_size) + y = np.array([0]) + + p = predict(Theta1, Theta2, X) + assert p.shape == (1, 1) + + def test_single_class(self): + """测试单类别""" + input_size = 2 + hidden_size = 3 + num_labels = 2 + m = 10 + + Theta1 = randInitializeWeights(input_size, hidden_size) + Theta2 = randInitializeWeights(hidden_size, num_labels) + nn_params = np.vstack((Theta1.reshape(-1, 1), Theta2.reshape(-1, 1))) + + X = np.random.rand(m, input_size) + y = np.zeros(m) # 所有样本属于类别 0 + + J = nnCostFunction(nn_params, input_size, hidden_size, num_labels, X, y, 0) + assert J >= 0 + + def test_zero_weights(self): + """测试零权重""" + input_size = 2 + hidden_size = 3 + num_labels = 2 + m = 10 + + Theta1 = np.zeros((hidden_size, input_size + 1)) + Theta2 = np.zeros((num_labels, hidden_size + 1)) + nn_params = np.vstack((Theta1.reshape(-1, 1), Theta2.reshape(-1, 1))) + + X = np.random.rand(m, input_size) + y = np.random.randint(0, num_labels, m) + + J = nnCostFunction(nn_params, input_size, hidden_size, num_labels, X, y, 0) + # 零权重时代价应约为 -ln(0.5) = 0.693 + np.testing.assert_almost_equal(J, 0.693, decimal=2) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_pca.py b/tests/test_pca.py new file mode 100644 index 0000000..7a3a2b3 --- /dev/null +++ b/tests/test_pca.py @@ -0,0 +1,423 @@ +# -*- coding: utf-8 -*- +""" +PCA主成分分析算法测试模块 +测试内容: +- 数据加载功能 +- 特征归一化 +- 协方差矩阵计算 +- 奇异值分解 +- 数据投影 +- 数据恢复 +- 端到端集成测试 +- 与 scikit-learn 对比 +""" +import pytest +import numpy as np +import os +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, PROJECT_ROOT) + +# 动态导入模块 +import importlib.util +spec = importlib.util.spec_from_file_location("PCA", + os.path.join(PROJECT_ROOT, "PCA", "PCA.py")) +PCA = importlib.util.module_from_spec(spec) +spec.loader.exec_module(PCA) + +featureNormalize = PCA.featureNormalize +projectData = PCA.projectData +recoverData = PCA.recoverData +display_imageData = PCA.display_imageData + +from scipy import io as spio + + +try: + from sklearn.decomposition import PCA as SklearnPCA + from sklearn.preprocessing import StandardScaler + SKLEARN_AVAILABLE = True +except ImportError: + SKLEARN_AVAILABLE = False + + +class TestDataLoading: + """数据加载测试类""" + + def test_load_mat_data(self, pca_data_path): + """测试 mat 文件加载""" + if os.path.exists(pca_data_path): + data = spio.loadmat(pca_data_path) + assert isinstance(data, dict) + assert 'X' in data + X = data['X'] + assert isinstance(X, np.ndarray) + assert X.ndim == 2 + + +class TestFeatureNormalization: + """特征归一化测试类""" + + def test_feature_normalize_shape(self, sample_regression_data): + """测试归一化后形状""" + X, _, _ = sample_regression_data + X_norm, mu, sigma = featureNormalize(X) + assert X_norm.shape == X.shape + assert mu.shape == (X.shape[1],) + assert sigma.shape == (X.shape[1],) + + def test_feature_normalize_zero_mean(self, sample_regression_data): + """测试归一化后均值为0""" + X, _, _ = sample_regression_data + X_norm, mu, sigma = featureNormalize(X) + np.testing.assert_array_almost_equal( + np.mean(X_norm, axis=0), np.zeros(X.shape[1]), decimal=10 + ) + + def test_feature_normalize_unit_std(self, sample_regression_data): + """测试归一化后标准差为1""" + X, _, _ = sample_regression_data + X_norm, mu, sigma = featureNormalize(X) + np.testing.assert_array_almost_equal( + np.std(X_norm, axis=0), np.ones(X.shape[1]), decimal=10 + ) + + def test_feature_normalize_inverse(self, sample_regression_data): + """测试归一化可逆""" + X, _, _ = sample_regression_data + X_norm, mu, sigma = featureNormalize(X) + X_recovered = X_norm * sigma + mu + np.testing.assert_array_almost_equal(X, X_recovered) + + +class TestProjection: + """数据投影测试类""" + + def test_project_data_shape(self): + """测试投影后形状""" + np.random.seed(42) + X = np.random.randn(100, 5) + X_norm, mu, sigma = featureNormalize(X) + + # 计算PCA + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm), X_norm) / m + U, S, V = np.linalg.svd(Sigma) + + K = 2 + Z = projectData(X_norm, U, K) + assert Z.shape == (100, 2) + + def test_project_data_reduces_dimension(self): + """测试降维""" + np.random.seed(42) + X = np.random.randn(50, 10) + X_norm, mu, sigma = featureNormalize(X) + + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm), X_norm) / m + U, S, V = np.linalg.svd(Sigma) + + for K in [1, 3, 5, 9]: + Z = projectData(X_norm, U, K) + assert Z.shape == (50, K) + + def test_project_data_orthogonality(self): + """测试投影的正交性""" + np.random.seed(42) + X = np.random.randn(50, 5) + X_norm, mu, sigma = featureNormalize(X) + + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm), X_norm) / m + U, S, V = np.linalg.svd(Sigma) + + # U的列应该是正交的 + identity = np.dot(U.T, U) + np.testing.assert_array_almost_equal(identity, np.eye(5), decimal=5) + + +class TestRecovery: + """数据恢复测试类""" + + def test_recover_data_shape(self): + """测试恢复后形状""" + np.random.seed(42) + X = np.random.randn(100, 5) + X_norm, mu, sigma = featureNormalize(X) + + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm), X_norm) / m + U, S, V = np.linalg.svd(Sigma) + + K = 2 + Z = projectData(X_norm, U, K) + X_rec = recoverData(Z, U, K) + assert X_rec.shape == X_norm.shape + + def test_recover_data_approximation(self): + """测试恢复是近似""" + np.random.seed(42) + X = np.random.randn(50, 5) + X_norm, mu, sigma = featureNormalize(X) + + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm), X_norm) / m + U, S, V = np.linalg.svd(Sigma) + + # 使用全部主成分应能完美恢复 + K = 5 + Z = projectData(X_norm, U, K) + X_rec = recoverData(Z, U, K) + np.testing.assert_array_almost_equal(X_norm, X_rec, decimal=5) + + def test_recover_data_error_increases_with_less_components(self): + """测试使用更少主成分时误差增大""" + np.random.seed(42) + X = np.random.randn(50, 5) + X_norm, mu, sigma = featureNormalize(X) + + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm), X_norm) / m + U, S, V = np.linalg.svd(Sigma) + + errors = [] + for K in [1, 2, 3, 4, 5]: + Z = projectData(X_norm, U, K) + X_rec = recoverData(Z, U, K) + error = np.mean((X_norm - X_rec) ** 2) + errors.append(error) + + # 误差应随K减小而单调递减 + for i in range(len(errors) - 1): + assert errors[i] >= errors[i + 1] * 0.99 + + +class TestSVD: + """奇异值分解测试类""" + + def test_svd_reconstruction(self): + """测试SVD重构""" + np.random.seed(42) + A = np.random.randn(5, 5) + U, S, V = np.linalg.svd(A) + + # 重构 + S_matrix = np.diag(S) + A_reconstructed = np.dot(U, np.dot(S_matrix, V)) + np.testing.assert_array_almost_equal(A, A_reconstructed) + + def test_svd_properties(self): + """测试SVD性质""" + np.random.seed(42) + A = np.random.randn(10, 5) + U, S, V = np.linalg.svd(A, full_matrices=False) + + # U和V应该是正交矩阵 + np.testing.assert_array_almost_equal(np.dot(U.T, U), np.eye(5), decimal=5) + np.testing.assert_array_almost_equal(np.dot(V, V.T), np.eye(5), decimal=5) + + # 奇异值应为正 + assert np.all(S > 0) + + +class TestIntegration: + """集成测试类""" + + def test_pca_end_to_end(self): + """端到端PCA测试""" + np.random.seed(42) + # 创建相关数据 + n_samples = 100 + x1 = np.random.randn(n_samples) + x2 = 0.5 * x1 + 0.1 * np.random.randn(n_samples) + x3 = np.random.randn(n_samples) + X = np.column_stack([x1, x2, x3]) + + # 归一化 + X_norm, mu, sigma = featureNormalize(X) + + # PCA + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm), X_norm) / m + U, S, V = np.linalg.svd(Sigma) + + # 降维到2D + K = 2 + Z = projectData(X_norm, U, K) + + # 恢复 + X_rec = recoverData(Z, U, K) + + # 验证 + assert Z.shape == (n_samples, K) + assert X_rec.shape == X_norm.shape + + # 恢复误差应较小(因为x1和x2相关) + error = np.mean((X_norm - X_rec) ** 2) + assert error < 0.5 + + def test_pca_variance_retention(self): + """测试方差保留""" + np.random.seed(42) + X = np.random.randn(100, 10) + X_norm, mu, sigma = featureNormalize(X) + + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm), X_norm) / m + U, S, V = np.linalg.svd(Sigma) + + # 计算保留的方差比例 + total_variance = np.sum(S) + for K in [1, 3, 5, 10]: + retained_variance = np.sum(S[:K]) / total_variance + # 保留的方差应随K增加 + assert 0 <= retained_variance <= 1 + + def test_pca_on_image_data(self): + """测试图像数据PCA""" + np.random.seed(42) + # 模拟图像数据 (32x32 = 1024维) + n_samples = 50 + n_features = 1024 + X = np.random.randn(n_samples, n_features) * 50 + 128 + + # 归一化 + X_norm, mu, sigma = featureNormalize(X) + + # PCA + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm), X_norm) / m + U, S, V = np.linalg.svd(Sigma) + + # 降维到100维 + K = 100 + Z = projectData(X_norm, U, K) + X_rec = recoverData(Z, U, K) + + assert Z.shape == (n_samples, K) + assert X_rec.shape == X_norm.shape + + +class TestEdgeCases: + """边界情况测试类""" + + def test_single_feature(self): + """测试单特征""" + X = np.random.randn(50, 1) + X_norm, mu, sigma = featureNormalize(X) + + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm), X_norm) / m + U, S, V = np.linalg.svd(Sigma) + + Z = projectData(X_norm, U, 1) + X_rec = recoverData(Z, U, 1) + + np.testing.assert_array_almost_equal(X_norm, X_rec, decimal=5) + + def test_single_sample(self): + """测试单样本""" + X = np.random.randn(1, 5) + X_norm, mu, sigma = featureNormalize(X) + + # 单样本PCA(方差为0) + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm), X_norm) / m + U, S, V = np.linalg.svd(Sigma) + + # 所有奇异值应为0 + np.testing.assert_array_almost_equal(S, np.zeros(5)) + + def test_zero_variance_feature(self): + """测试零方差特征""" + X = np.column_stack([ + np.random.randn(50), + np.ones(50) # 常数特征 + ]) + X_norm, mu, sigma = featureNormalize(X) + + # 常数特征归一化后为NaN + assert np.any(np.isnan(X_norm)) + + def test_high_dimensional_reduction(self): + """测试高维降维""" + X = np.random.randn(10, 100) + X_norm, mu, sigma = featureNormalize(X) + + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm), X_norm) / m + U, S, V = np.linalg.svd(Sigma) + + # 降维到比样本数少的维度 + K = 5 + Z = projectData(X_norm, U, K) + assert Z.shape == (10, K) + + +class TestSklearnComparison: + """与 scikit-learn 对比测试类""" + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_compare_with_sklearn(self): + """与 sklearn PCA 对比""" + np.random.seed(42) + X = np.random.randn(100, 10) + + # 自定义实现 + X_norm_custom, mu, sigma = featureNormalize(X) + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm_custom), X_norm_custom) / m + U, S, V = np.linalg.svd(Sigma) + K = 5 + Z_custom = projectData(X_norm_custom, U, K) + X_rec_custom = recoverData(Z_custom, U, K) + + # sklearn 实现 + scaler = StandardScaler() + X_norm_sklearn = scaler.fit_transform(X) + pca = SklearnPCA(n_components=K) + Z_sklearn = pca.fit_transform(X_norm_sklearn) + X_rec_sklearn = pca.inverse_transform(Z_sklearn) + + # 投影结果形状相同 + assert Z_custom.shape == Z_sklearn.shape + assert X_rec_custom.shape == X_rec_sklearn.shape + + # 恢复误差应相近 + error_custom = np.mean((X_norm_custom - X_rec_custom) ** 2) + error_sklearn = np.mean((X_norm_sklearn - X_rec_sklearn) ** 2) + + ratio = error_custom / error_sklearn if error_sklearn > 0 else 1 + assert 0.5 <= ratio <= 2.0 + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_explained_variance_ratio(self): + """测试解释方差比例""" + np.random.seed(42) + X = np.random.randn(100, 10) + + # 自定义实现 + X_norm, mu, sigma = featureNormalize(X) + m = X.shape[0] + Sigma = np.dot(np.transpose(X_norm), X_norm) / m + U, S, V = np.linalg.svd(Sigma) + + # sklearn + scaler = StandardScaler() + X_norm_sklearn = scaler.fit_transform(X) + pca = SklearnPCA(n_components=10) + pca.fit(X_norm_sklearn) + + # 比较奇异值/解释方差 + # 注意:sklearn的explained_variance_是特征值,等于S^2/(m-1) + # 我们只比较相对比例 + custom_ratio = S / np.sum(S) + sklearn_ratio = pca.explained_variance_ / np.sum(pca.explained_variance_) + + np.testing.assert_array_almost_equal(custom_ratio, sklearn_ratio, decimal=2) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_svm.py b/tests/test_svm.py new file mode 100644 index 0000000..f24267e --- /dev/null +++ b/tests/test_svm.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- +""" +SVM支持向量机算法测试模块 +测试内容: +- 数据加载功能 +- 线性核SVM分类 +- 非线性核SVM分类(RBF) +- 不同C参数效果 +- 不同gamma参数效果 +- 与 scikit-learn 对比 +""" +import pytest +import numpy as np +import os +import sys + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, PROJECT_ROOT) + +from scipy import io as spio + + +try: + from sklearn import svm + SKLEARN_AVAILABLE = True +except ImportError: + SKLEARN_AVAILABLE = False + + +class TestDataLoading: + """数据加载测试类""" + + def test_load_mat_data(self, svm_data_path): + """测试 mat 文件加载""" + if os.path.exists(svm_data_path): + data = spio.loadmat(svm_data_path) + assert isinstance(data, dict) + assert 'X' in data + assert 'y' in data + + def test_data_shape(self, svm_data_path): + """测试数据形状""" + if os.path.exists(svm_data_path): + data = spio.loadmat(svm_data_path) + X = data['X'] + y = data['y'] + assert X.ndim == 2 + assert y.ndim == 2 + assert X.shape[0] == y.shape[0] + + +class TestLinearSVM: + """线性SVM测试类""" + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_linear_svm_fit(self, sample_classification_data): + """测试线性SVM拟合""" + X, y = sample_classification_data + model = svm.SVC(C=1.0, kernel='linear') + model.fit(X, y) + + assert hasattr(model, 'support_') + assert len(model.support_) > 0 + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_linear_svm_predict(self, sample_classification_data): + """测试线性SVM预测""" + X, y = sample_classification_data + model = svm.SVC(C=1.0, kernel='linear') + model.fit(X, y) + + predictions = model.predict(X) + assert predictions.shape == y.shape + assert np.all(np.isin(predictions, [0, 1])) + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_linear_svm_accuracy(self, sample_classification_data): + """测试线性SVM准确率""" + X, y = sample_classification_data + model = svm.SVC(C=1.0, kernel='linear') + model.fit(X, y) + + predictions = model.predict(X) + accuracy = np.mean(predictions == y) + # 准确率应高于随机猜测 + assert accuracy > 0.5 + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_linear_svm_coefficients(self, sample_classification_data): + """测试线性SVM系数""" + X, y = sample_classification_data + model = svm.SVC(C=1.0, kernel='linear') + model.fit(X, y) + + assert hasattr(model, 'coef_') + assert model.coef_.shape == (1, X.shape[1]) + + +class TestNonLinearSVM: + """非线性SVM测试类""" + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_rbf_svm_fit(self, sample_classification_data): + """测试RBF核SVM拟合""" + X, y = sample_classification_data + model = svm.SVC(kernel='rbf', gamma='scale') + model.fit(X, y) + + assert hasattr(model, 'support_') + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_rbf_svm_predict(self, sample_classification_data): + """测试RBF核SVM预测""" + X, y = sample_classification_data + model = svm.SVC(kernel='rbf', gamma='scale') + model.fit(X, y) + + predictions = model.predict(X) + assert predictions.shape == y.shape + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_rbf_svm_vs_linear(self): + """比较RBF和线性SVM在非线性数据上的表现""" + # 创建非线性数据(同心圆) + np.random.seed(42) + n_samples = 200 + + # 内圆 + theta = np.random.uniform(0, 2*np.pi, n_samples//2) + r = np.random.uniform(0, 1, n_samples//2) + X_inner = np.column_stack([r * np.cos(theta), r * np.sin(theta)]) + y_inner = np.zeros(n_samples//2) + + # 外圆 + theta = np.random.uniform(0, 2*np.pi, n_samples//2) + r = np.random.uniform(1.5, 2.5, n_samples//2) + X_outer = np.column_stack([r * np.cos(theta), r * np.sin(theta)]) + y_outer = np.ones(n_samples//2) + + X = np.vstack([X_inner, X_outer]) + y = np.hstack([y_inner, y_outer]) + + # 线性SVM + linear_model = svm.SVC(kernel='linear') + linear_model.fit(X, y) + linear_acc = np.mean(linear_model.predict(X) == y) + + # RBF SVM + rbf_model = svm.SVC(kernel='rbf', gamma='scale') + rbf_model.fit(X, y) + rbf_acc = np.mean(rbf_model.predict(X) == y) + + # RBF应比线性SVM表现更好 + assert rbf_acc >= linear_acc + + +class TestSVMParameters: + """SVM参数测试类""" + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_c_parameter_effect(self, sample_classification_data): + """测试C参数对模型的影响""" + X, y = sample_classification_data + + # 小C值(更多正则化) + model_small_c = svm.SVC(C=0.01, kernel='linear') + model_small_c.fit(X, y) + + # 大C值(更少正则化) + model_large_c = svm.SVC(C=100, kernel='linear') + model_large_c.fit(X, y) + + # 大C值通常有更多支持向量 + assert len(model_large_c.support_) >= len(model_small_c.support_) * 0.5 + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_gamma_parameter_effect(self, sample_classification_data): + """测试gamma参数对RBF核的影响""" + X, y = sample_classification_data + + # 小gamma + model_small_gamma = svm.SVC(kernel='rbf', gamma=0.01) + model_small_gamma.fit(X, y) + + # 大gamma + model_large_gamma = svm.SVC(kernel='rbf', gamma=100) + model_large_gamma.fit(X, y) + + # 两者都应该能拟合 + assert len(model_small_gamma.support_) > 0 + assert len(model_large_gamma.support_) > 0 + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_different_kernels(self, sample_classification_data): + """测试不同核函数""" + X, y = sample_classification_data + + kernels = ['linear', 'rbf', 'poly'] + for kernel in kernels: + model = svm.SVC(kernel=kernel, gamma='scale') + model.fit(X, y) + predictions = model.predict(X) + accuracy = np.mean(predictions == y) + assert accuracy > 0.5, f"Kernel {kernel} performed poorly" + + +class TestIntegration: + """集成测试类""" + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_svm_workflow(self, sample_classification_data): + """SVM完整工作流测试""" + X, y = sample_classification_data + + # 划分训练集和测试集 + from sklearn.model_selection import train_test_split + X_train, X_test, y_train, y_test = train_test_split( + X, y, test_size=0.3, random_state=42 + ) + + # 训练模型 + model = svm.SVC(C=1.0, kernel='rbf', gamma='scale') + model.fit(X_train, y_train) + + # 预测 + train_acc = np.mean(model.predict(X_train) == y_train) + test_acc = np.mean(model.predict(X_test) == y_test) + + # 训练准确率应高于测试准确率 + assert train_acc >= test_acc * 0.8 + # 测试准确率应高于随机猜测 + assert test_acc > 0.5 + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_multiclass_svm(self, sample_multiclass_data): + """多分类SVM测试""" + X, y = sample_multiclass_data + + model = svm.SVC(C=1.0, kernel='rbf', gamma='scale', decision_function_shape='ovr') + model.fit(X, y) + + predictions = model.predict(X) + accuracy = np.mean(predictions == y) + + # 多分类准确率应高于随机猜测 (1/3) + assert accuracy > 0.4 + + +class TestEdgeCases: + """边界情况测试类""" + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_single_sample(self): + """测试单样本""" + X = np.array([[1, 2]]) + y = np.array([0]) + + model = svm.SVC(kernel='linear') + model.fit(X, y) + + prediction = model.predict(X) + assert prediction[0] == 0 + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_all_same_class(self): + """测试所有样本属于同一类""" + X = np.random.randn(10, 2) + y = np.ones(10) + + model = svm.SVC(kernel='linear') + # 可能产生警告,但不应报错 + model.fit(X, y) + predictions = model.predict(X) + assert np.all(predictions == 1) + + @pytest.mark.skipif(not SKLEARN_AVAILABLE, reason="scikit-learn not installed") + def test_high_dimensional_data(self): + """测试高维数据""" + np.random.seed(42) + X = np.random.randn(50, 100) + y = np.random.randint(0, 2, 50) + + model = svm.SVC(kernel='linear') + model.fit(X, y) + predictions = model.predict(X) + + assert predictions.shape == y.shape + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])