diff --git a/orca_python/classifiers/NNOP.py b/orca_python/classifiers/NNOP.py index 34f56a7..57852b6 100644 --- a/orca_python/classifiers/NNOP.py +++ b/orca_python/classifiers/NNOP.py @@ -11,14 +11,14 @@ from sklearn.utils.validation import check_array, check_is_fitted, check_X_y -class NNOP(BaseEstimator, ClassifierMixin): +class NNOP(ClassifierMixin, BaseEstimator): """Neural Network with Ordered Partitions (NNOP). This model considers the OrderedPartitions coding scheme for the labels and a rule for decisions based on the first node whose output is higher than a predefined - threshold (T=0.5, in our experiments). The model has one hidden layer with hiddenN - neurons and one output layer with as many neurons as the number of classes minus - one. + threshold (T=0.5, in our experiments). The model has one hidden layer with + "n_hidden" neurons and one output layer with as many neurons as the number of + classes minus one. The learning is based on iRProp+ algorithm and the implementation provided by Roberto Calandra in his toolbox Rprop Toolbox for MATLAB: @@ -37,7 +37,8 @@ class NNOP(BaseEstimator, ClassifierMixin): Number of hidden neurons of the model. max_iter : int, default=500 - Number of iterations for fmin_l_bfgs_b algorithm. + Maximum number of iterations. The solver iterates until convergence or this + number of iterations. lambda_value : float, default=0.01 Regularization parameter. @@ -45,13 +46,25 @@ class NNOP(BaseEstimator, ClassifierMixin): Attributes ---------- classes_ : ndarray of shape (n_classes,) - Array that contains all different class labels found in the original dataset. + Class labels for each output. - n_classes_ : int - Number of labels in the problem. + loss_ : float + The current loss computed with the loss function. - n_samples_ : int - Number of samples of X (train patterns array). + n_features_in_ : int + Number of features seen during fit. + + n_iter_ : int + The number of iterations the solver has run. + + n_layers_ : int + Number of layers. + + n_outputs_ : int + Number of outputs. + + out_activation_ : str + Name of the output activation function. theta1_ : ndarray of shape (n_hidden, n_features + 1) Hidden layer weights (with bias). @@ -106,16 +119,15 @@ def __init__(self, epsilon_init=0.5, n_hidden=50, max_iter=500, lambda_value=0.0 @_fit_context(prefer_skip_nested_validation=True) def fit(self, X, y): - """Fit the model with the training data. + """Fit the model to data matrix X and target(s) y. Parameters ---------- - X : {array-like, sparse matrix} of shape (n_samples, n_features) - Training patterns array, where n_samples is the number of samples - and n_features is the number of features. + X : ndarray or sparse matrix of shape (n_samples, n_features) + The input data. - y : array-like of shape (n_samples,) - Target vector relative to X. + y : ndarray of shape (n_samples,) + The target values. Returns ------- @@ -128,14 +140,6 @@ def fit(self, X, y): If parameters are invalid or data has wrong format. """ - if ( - self.epsilon_init < 0 - or self.n_hidden < 1 - or self.max_iter < 1 - or self.lambda_value < 0 - ): - return None - # Check that X and y have correct shape X, y = check_X_y(X, y) # Store the classes seen during fit @@ -143,9 +147,9 @@ def fit(self, X, y): # Aux variables y = y[:, np.newaxis] - n_features = X.shape[1] - n_classes = np.size(np.unique(y)) + n_classes = len(self.classes_) n_samples = X.shape[0] + self.n_features_in_ = X.shape[1] # Recode y to Y using ordinalPartitions coding Y = 1 * ( @@ -154,7 +158,9 @@ def fit(self, X, y): ) # Hidden layer weights (with bias) - initial_theta1 = self._rand_initialize_weights(n_features + 1, self.n_hidden) + initial_theta1 = self._rand_initialize_weights( + self.n_features_in_ + 1, self.n_hidden + ) # Output layer weights initial_theta2 = self._rand_initialize_weights(self.n_hidden + 1, n_classes - 1) @@ -167,22 +173,34 @@ def fit(self, X, y): results_optimization = scipy.optimize.fmin_l_bfgs_b( func=self._nnop_cost_function, x0=initial_nn_params.ravel(), - args=(n_features, self.n_hidden, n_classes, X, Y, self.lambda_value), + args=( + self.n_features_in_, + self.n_hidden, + n_classes, + X, + Y, + self.lambda_value, + ), fprime=None, factr=1e3, maxiter=self.max_iter, - iprint=-1, ) - self.nn_params = results_optimization[0] + nn_params = results_optimization[0] + self.loss_ = float(results_optimization[1]) + self.n_iter_ = int(results_optimization[2].get("nit", 0)) + # Unpack the parameters theta1, theta2 = self._unpack_parameters( - self.nn_params, n_features, self.n_hidden, n_classes + nn_params, self.n_features_in_, self.n_hidden, n_classes ) self.theta1_ = theta1 self.theta2_ = theta2 - self.n_classes_ = n_classes - self.n_samples_ = n_samples + + # Scikit-learn compatibility + self.n_layers_ = 3 + self.n_outputs_ = n_classes - 1 + self.out_activation_ = "logistic" return self @@ -192,13 +210,12 @@ def predict(self, X): Parameters ---------- X : {array-like, sparse matrix} of shape (n_samples, n_features) - Test patterns array, where n_samples is the number of samples and n_features - is the number of features. + The input data. Returns ------- y_pred : ndarray of shape (n_samples,) - Class labels for samples in X. + The predicted classes. Raises ------ @@ -210,11 +227,12 @@ def predict(self, X): """ # Check is fit had been called - check_is_fitted(self) + check_is_fitted(self, attributes=["theta1_", "theta2_", "classes_"]) # Input validation X = check_array(X) n_samples = X.shape[0] + n_classes = len(self.classes_) a1 = np.append(np.ones((n_samples, 1)), X, axis=1) z2 = np.append(np.ones((n_samples, 1)), np.matmul(a1, self.theta1_.T), axis=1) @@ -225,189 +243,13 @@ def predict(self, X): a3 = np.multiply( np.where(np.append(projected, np.ones((n_samples, 1)), axis=1) > 0.5, 1, 0), - np.tile(np.arange(1, self.n_classes_ + 1), (n_samples, 1)), + np.tile(np.arange(1, n_classes + 1), (n_samples, 1)), ) - a3[np.where(a3 == 0)] = self.n_classes_ + 1 + a3[np.where(a3 == 0)] = n_classes + 1 y_pred = a3.min(axis=1) return y_pred - def get_epsilon_init(self): - """Return the value of the variable self.epsilon_init. - - Returns - ------- - epsilon_init : float - The initialization range of the weights. - - """ - return self.epsilon_init - - def set_epsilon_init(self, epsilon_init): - """Modify the value of the variable self.epsilon_init. - - Parameters - ---------- - epsilon_init : float - The initialization range of the weights. - - """ - self.epsilon_init = epsilon_init - - def get_n_hidden(self): - """Return the value of the variable self.n_hidden. - - Returns - ------- - n_hidden : int - Number of nodes/neurons in the hidden layer. - - """ - return self.n_hidden - - def set_n_hidden(self, n_hidden): - """Modify the value of the variable self.n_hidden. - - Parameters - ---------- - n_hidden : int - Number of nodes/neurons in the hidden layer. - - """ - self.n_hidden = n_hidden - - def get_max_iter(self): - """Return the value of the variable self.max_iter. - - Returns - ------- - max_iter : int - Number of iterations. - - """ - return self.max_iter - - def set_max_iter(self, max_iter): - """Modify the value of the variable self.max_iter. - - Parameters - ---------- - max_iter : int - Number of iterations. - - """ - self.max_iter = max_iter - - def get_lambda_value(self): - """Return the value of the variable self.lambda_value. - - Returns - ------- - lambda_value : float - Lambda parameter used in regularization. - - """ - return self.lambda_value - - def set_lambda_value(self, lambda_value): - """Modify the value of the variable self.lambda_value. - - Parameters - ---------- - lambda_value : float - Lambda parameter used in regularization. - - """ - self.lambda_value = lambda_value - - def get_theta1(self): - """Return the value of the variable self.theta1_. - - Returns - ------- - theta1_ : ndarray of shape (n_hidden, n_features + 1) - Array with the weights of the hidden layer (with biases included). - - """ - return self.theta1_ - - def set_theta1(self, theta1): - """Modify the value of the variable self.theta1_. - - Parameters - ---------- - theta1 : ndarray of shape (n_hidden, n_features + 1) - Array with the weights of the hidden layer (with biases included). - - """ - self.theta1_ = theta1 - - def get_theta2(self): - """Return the value of the variable self.theta2_. - - Returns - ------- - theta2_ : ndarray of shape (n_classes - 1, n_hidden + 1) - Array with the weights of the output layer. - - """ - return self.theta2_ - - def set_theta2(self, theta2): - """Modify the value of the variable self.theta2_. - - Parameters - ---------- - theta2 : ndarray of shape (n_classes - 1, n_hidden + 1) - Array with the weights of the output layer. - - """ - self.theta2_ = theta2 - - def get_n_classes(self): - """Return the value of the variable self.n_classes_. - - Returns - ------- - n_classes_ : int - Number of labels in the problem. - - """ - return self.n_classes_ - - def set_n_classes(self, n_classes): - """Modify the value of the variable self.n_classes_. - - Parameters - ---------- - n_classes : int - Number of labels in the problem. - - """ - self.n_classes_ = n_classes - - def get_n_samples(self): - """Return the value of the variable self.n_samples_. - - Returns - ------- - n_samples_ : int - Number of samples of X (train patterns array). - - """ - return self.n_samples_ - - def set_n_samples(self, n_samples): - """Modify the value of the variable self.n_samples_. - - Parameters - ---------- - n_samples : int - Number of samples of X (train patterns array). - - """ - self.n_samples_ = n_samples - def _unpack_parameters(self, nn_params, n_features, n_hidden, n_classes): """Get theta1 and theta2 back from nn_params. @@ -468,10 +310,7 @@ def _rand_initialize_weights(self, L_in, L_out): Array with the weights of each synaptic relationship between nodes. """ - W = ( - np.random.rand(L_out, L_in) * 2 * self.get_epsilon_init() - - self.get_epsilon_init() - ) + W = np.random.rand(L_out, L_in) * 2 * self.epsilon_init - self.epsilon_init return W diff --git a/orca_python/classifiers/NNPOM.py b/orca_python/classifiers/NNPOM.py index 74c69de..c38d3ba 100644 --- a/orca_python/classifiers/NNPOM.py +++ b/orca_python/classifiers/NNPOM.py @@ -11,11 +11,11 @@ from sklearn.utils.validation import check_array, check_is_fitted, check_X_y -class NNPOM(BaseEstimator, ClassifierMixin): +class NNPOM(ClassifierMixin, BaseEstimator): """Neural Network based on Proportional Odd Model (NNPOM). This class implements a neural network model for ordinal regression. The model has - one hidden layer with n_hidden neurons and one output layer with only one neuron + one hidden layer with "n_hidden" neurons and one output layer with only one neuron but as many thresholds as the number of classes minus one. The standard POM model is applied in this neuron to have probabilistic outputs. @@ -36,7 +36,8 @@ class NNPOM(BaseEstimator, ClassifierMixin): Number of hidden neurons of the model. max_iter : int, default=500 - Number of iterations for fmin_l_bfgs_b algorithm. + Maximum number of iterations. The solver iterates until convergence or this + number of iterations. lambda_value : float, default=0.01 Regularization parameter. @@ -44,13 +45,25 @@ class NNPOM(BaseEstimator, ClassifierMixin): Attributes ---------- classes_ : ndarray of shape (n_classes,) - Array that contains all different class labels found in the original dataset. + Class labels for each output. - n_classes_ : int - Number of labels in the problem + loss_ : float + The current loss computed with the loss function. - n_samples_ : int - Number of samples of X (train patterns array). + n_features_in_ : int + Number of features seen during fit. + + n_iter_ : int + The number of iterations the solver has run. + + n_layers_ : int + Number of layers. + + n_outputs_ : int + Number of outputs. + + out_activation_ : str + Name of the output activation function. theta1_ : ndarray of shape (n_hidden, n_features + 1) Hidden layer weigths (with bias) @@ -107,16 +120,15 @@ def __init__(self, epsilon_init=0.5, n_hidden=50, max_iter=500, lambda_value=0.0 @_fit_context(prefer_skip_nested_validation=True) def fit(self, X, y): - """Fit the model with the training data. + """Fit the model to data matrix X and target(s) y. Parameters ---------- - X : {array-like, sparse matrix} of shape (n_samples, n_features) - Training patterns array, where n_samples is the number of samples and - n_features is the number of features. + X : ndarray or sparse matrix of shape (n_samples, n_features) + The input data. y : array-like of shape (n_samples,) - Target vector relative to X. + The target values. Returns ------- @@ -129,14 +141,6 @@ def fit(self, X, y): If parameters are invalid or data has wrong format. """ - if ( - self.epsilon_init < 0 - or self.n_hidden < 1 - or self.max_iter < 1 - or self.lambda_value < 0 - ): - return None - # Check that X and y have correct shape X, y = check_X_y(X, y) # Store the classes seen during fit @@ -144,9 +148,9 @@ def fit(self, X, y): # Aux variables y = y[:, np.newaxis] - n_features = X.shape[1] - n_classes = np.size(np.unique(y)) + n_classes = len(self.classes_) n_samples = X.shape[0] + self.n_features_in_ = X.shape[1] # Recode y to Y using nominal coding Y = 1 * ( @@ -156,10 +160,10 @@ def fit(self, X, y): # Hidden layer weigths (with bias) initial_theta1 = self._rand_initialize_weights( - n_features + 1, self.get_n_hidden() + self.n_features_in_ + 1, self.n_hidden ) # Output layer weigths (without bias, the biases will be the thresholds) - initial_theta2 = self._rand_initialize_weights(self.get_n_hidden(), 1) + initial_theta2 = self._rand_initialize_weights(self.n_hidden, 1) # Class thresholds parameters initial_thresholds = self._rand_initialize_weights((n_classes - 1), 1) @@ -176,25 +180,36 @@ def fit(self, X, y): results_optimization = scipy.optimize.fmin_l_bfgs_b( func=self._nnpom_cost_function, x0=initial_nn_params.ravel(), - args=(n_features, self.n_hidden, n_classes, X, Y, self.lambda_value), + args=( + self.n_features_in_, + self.n_hidden, + n_classes, + X, + Y, + self.lambda_value, + ), fprime=None, factr=1e3, maxiter=self.max_iter, - iprint=-1, ) - self.nn_params = results_optimization[0] + nn_params = results_optimization[0] + self.loss_ = float(results_optimization[1]) + self.n_iter_ = int(results_optimization[2].get("nit", 0)) # Unpack the parameters theta1, theta2, thresholds_param = self._unpack_parameters( - self.nn_params, n_features, self.n_hidden, n_classes + nn_params, self.n_features_in_, self.n_hidden, n_classes ) self.theta1_ = theta1 self.theta2_ = theta2 self.thresholds_ = self._convert_thresholds(thresholds_param, n_classes) - self.n_classes_ = n_classes - self.n_samples_ = n_samples + + # Scikit-learn compatibility + self.n_layers_ = 3 + self.n_outputs_ = n_classes - 1 + self.out_activation_ = "logistic" return self @@ -204,13 +219,12 @@ def predict(self, X): Parameters ---------- X : {array-like, sparse matrix} of shape (n_samples, n_features) - Test patterns array, where n_samples is the number of samples and - n_features is the number of features. + The input data. Returns ------- y_pred : ndarray of shape (n_samples,) - Class labels for samples in X. + The predicted classes. Raises ------ @@ -222,12 +236,13 @@ def predict(self, X): """ # Check is fit had been called - check_is_fitted(self) + check_is_fitted(self, attributes=["theta1_", "theta2_", "classes_"]) # Input validation X = check_array(X) n_samples = X.shape[0] + n_classes = len(self.classes_) a1 = np.append(np.ones((n_samples, 1)), X, axis=1) z2 = np.matmul(a1, self.theta1_.T) @@ -235,7 +250,7 @@ def predict(self, X): projected = np.matmul(a2, self.theta2_.T) z3 = np.tile(self.thresholds_, (n_samples, 1)) - np.tile( - projected, (1, self.n_classes_ - 1) + projected, (1, n_classes - 1) ) a3T = 1.0 / (1.0 + np.exp(-z3)) a3 = np.append(a3T, np.ones((n_samples, 1)), axis=1) @@ -244,206 +259,6 @@ def predict(self, X): return y_pred - def get_epsilon_init(self): - """Return the value of the variable self.epsilon_init. - - Returns - ------- - epsilon_init : float - The initialization range of the weights. - - """ - return self.epsilon_init - - def set_epsilon_init(self, epsilon_init): - """Modify the value of the variable self.epsilon_init. - - Parameters - ---------- - epsilon_init : float - The initialization range of the weights. - - """ - self.epsilon_init = epsilon_init - - def get_n_hidden(self): - """Return the value of the variable self.n_hidden. - - Returns - ------- - n_hidden : int - Number of nodes/neurons in the hidden layer. - - """ - return self.n_hidden - - def set_n_hidden(self, n_hidden): - """Modify the value of the variable self.n_hidden. - - Parameters - ---------- - n_hidden : int - Number of nodes/neurons in the hidden layer. - - """ - self.n_hidden = n_hidden - - def get_max_iter(self): - """Return the value of the variable self.max_iter. - - Returns - ------- - max_iter : int - Number of iterations. - - """ - return self.max_iter - - def set_max_iter(self, max_iter): - """Modify the value of the variable self.max_iter. - - Parameters - ---------- - max_iter : int - Number of iterations. - - """ - self.max_iter = max_iter - - def get_lambda_value(self): - """Return the value of the variable self.lambda_value. - - Returns - ------- - lambda_value : float - The regularization parameter. - - """ - return self.lambda_value - - def set_lambda_value(self, lambda_value): - """Modify the value of the variable self.lambda_value. - - Parameters - ---------- - lambda_value : float - The regularization parameter. - - """ - self.lambda_value = lambda_value - - def get_theta1(self): - """Return the value of the variable self.theta1_. - - Returns - ------- - theta1_ : ndarray of shape (n_hidden, n_features + 1) - The weights of the hidden layer (with biases included). - - """ - return self.theta1_ - - def set_theta1(self, theta1): - """Modify the value of the variable self.theta1_. - - Parameters - ---------- - theta1 : ndarray of shape (n_hidden, n_features + 1) - The weights of the hidden layer (with biases included). - - """ - self.theta1_ = theta1 - - def get_theta2(self): - """Return the value of the variable self.theta2_. - - Returns - ------- - theta2_ : ndarray of shape (1, n_hidden) - The weights of the output layer (without bias, the biases will be the - thresholds). - - """ - return self.theta2_ - - def set_theta2(self, theta2): - """Modify the value of the variable self.theta2_. - - Parameters - ---------- - theta2 : ndarray of shape (1, n_hidden) - The weights of the output layer (without bias, the biases will be the - thresholds). - - """ - self.theta2_ = theta2 - - def get_thresholds(self): - """Return the value of the variable self.thresholds_. - - Returns - ------- - thresholds_ : ndarray of shape (n_classes - 1, 1) - The class thresholds parameters. - - """ - return self.thresholds_ - - def set_thresholds(self, thresholds): - """Modify the value of the variable self.thresholds_. - - Parameters - ---------- - thresholds : ndarray of shape (n_classes - 1, 1) - The class thresholds parameters. - - """ - self.thresholds_ = thresholds - - def get_n_classes(self): - """Return the value of the variable self.n_classes_. - - Returns - ------- - n_classes_ : int - Number of labels in the problem. - - """ - return self.n_classes_ - - def set_n_classes(self, n_classes): - """Modify the value of the variable self.n_classes_. - - Parameters - ---------- - n_classes : int - Number of labels in the problem. - - """ - self.n_classes_ = n_classes - - def get_n_samples(self): - """Return the value of the variable self.n_samples_. - - Returns - ------- - n_samples_ : int - Number of samples of X (train patterns array). - - """ - return self.n_samples_ - - def set_n_samples(self, n_samples): - """Modify the value of the variable self.n_samples_. - - Parameters - ---------- - n_samples : int - Number of samples of X (train patterns array). - - """ - self.n_samples_ = n_samples - def _unpack_parameters(self, nn_params, n_features, n_hidden, n_classes): """Get theta1, theta2 and thresholds_param from nn_params. @@ -513,10 +328,7 @@ def _rand_initialize_weights(self, L_in, L_out): Array with the weights of each synaptic relationship between nodes. """ - W = ( - np.random.rand(L_out, L_in) * 2 * self.get_epsilon_init() - - self.get_epsilon_init() - ) + W = np.random.rand(L_out, L_in) * 2 * self.epsilon_init - self.epsilon_init return W diff --git a/orca_python/classifiers/tests/test_nnop.py b/orca_python/classifiers/tests/test_nnop.py index 46da287..8e3e563 100644 --- a/orca_python/classifiers/tests/test_nnop.py +++ b/orca_python/classifiers/tests/test_nnop.py @@ -2,6 +2,7 @@ import numpy as np import pytest +from sklearn.exceptions import NotFittedError from orca_python.classifiers.NNOP import NNOP @@ -53,6 +54,13 @@ def test_nnop_hyperparameter_type_validation(X, y, param_name, invalid_value): classifier.fit(X, y) +def test_nnop_fit_returns_self(X, y): + """fit should return self for sklearn compatibility.""" + classifier = NNOP() + model = classifier.fit(X, y) + assert model is classifier + + def test_nnop_fit_input_validation(X, y): """Test that input data is validated.""" X_invalid = X[:-1, :-1] @@ -60,20 +68,45 @@ def test_nnop_fit_input_validation(X, y): classifier = NNOP() with pytest.raises(ValueError): - model = classifier.fit(X, y_invalid) - assert model is None, "The NNOP fit method doesnt return Null on error" + classifier.fit(X, y_invalid) with pytest.raises(ValueError): - model = classifier.fit([], y) - assert model is None, "The NNOP fit method doesnt return Null on error" + classifier.fit([], y) with pytest.raises(ValueError): - model = classifier.fit(X, []) - assert model is None, "The NNOP fit method doesnt return Null on error" + classifier.fit(X, []) with pytest.raises(ValueError): - model = classifier.fit(X_invalid, y) - assert model is None, "The NNOP fit method doesnt return Null on error" + classifier.fit(X_invalid, y) + + +def test_nnop_sets_fitted_attributes_after_fit(X, y): + """Test than NNOP exposes fitted attributes aligned con sklearn-style.""" + clf = NNOP(n_hidden=4, max_iter=5) + clf.fit(X, y) + + for attr in [ + "classes_", + "n_features_in_", + "theta1_", + "theta2_", + "loss_", + "n_iter_", + "n_layers_", + "n_outputs_", + "out_activation_", + ]: + assert hasattr(clf, attr), f"Missing fitted attribute: {attr}" + + assert isinstance(clf.classes_, np.ndarray) and np.array_equal( + clf.classes_, np.unique(y) + ) + assert isinstance(clf.n_features_in_, int) and clf.n_features_in_ == X.shape[1] + assert isinstance(clf.loss_, (float, np.floating)) and clf.loss_ >= 0 + assert isinstance(clf.n_iter_, int) and clf.n_iter_ == 5 + assert isinstance(clf.n_layers_, int) and clf.n_layers_ == 3 + assert isinstance(clf.n_outputs_, int) and clf.n_outputs_ == len(np.unique(y)) - 1 + assert isinstance(clf.out_activation_, str) and clf.out_activation_ == "logistic" def test_nnop_predict_invalid_input_raises_error(X, y): @@ -83,3 +116,10 @@ def test_nnop_predict_invalid_input_raises_error(X, y): with pytest.raises(ValueError): classifier.predict([]) + + +def test_nnop_predict_raises_if_not_fitted(X): + """Test that predict raises NotFittedError if called before fit.""" + classifier = NNOP() + with pytest.raises(NotFittedError): + classifier.predict(X) diff --git a/orca_python/classifiers/tests/test_nnpom.py b/orca_python/classifiers/tests/test_nnpom.py index c992db4..5efbd16 100644 --- a/orca_python/classifiers/tests/test_nnpom.py +++ b/orca_python/classifiers/tests/test_nnpom.py @@ -2,6 +2,7 @@ import numpy as np import pytest +from sklearn.exceptions import NotFittedError from orca_python.classifiers.NNPOM import NNPOM @@ -53,6 +54,13 @@ def test_nnpom_hyperparameter_type_validation(X, y, param_name, invalid_value): classifier.fit(X, y) +def test_nnpom_fit_returns_self(X, y): + """fit should return self for sklearn compatibility.""" + classifier = NNPOM() + model = classifier.fit(X, y) + assert model is classifier + + def test_nnpom_fit_input_validation(X, y): """Test that input data is validated.""" X_invalid = X[:-1, :-1] @@ -60,20 +68,45 @@ def test_nnpom_fit_input_validation(X, y): classifier = NNPOM() with pytest.raises(ValueError): - model = classifier.fit(X, y_invalid) - assert model is None, "The NNPOM fit method doesnt return Null on error" + classifier.fit(X, y_invalid) with pytest.raises(ValueError): - model = classifier.fit([], y) - assert model is None, "The NNPOM fit method doesnt return Null on error" + classifier.fit([], y) with pytest.raises(ValueError): - model = classifier.fit(X, []) - assert model is None, "The NNPOM fit method doesnt return Null on error" + classifier.fit(X, []) with pytest.raises(ValueError): - model = classifier.fit(X_invalid, y) - assert model is None, "The NNPOM fit method doesnt return Null on error" + classifier.fit(X_invalid, y) + + +def test_nnpom_sets_fitted_attributes_after_fit(X, y): + """Test than NNPOM exposes fitted attributes aligned con sklearn-style.""" + clf = NNPOM(n_hidden=4, max_iter=5) + clf.fit(X, y) + + for attr in [ + "classes_", + "n_features_in_", + "theta1_", + "theta2_", + "loss_", + "n_iter_", + "n_layers_", + "n_outputs_", + "out_activation_", + ]: + assert hasattr(clf, attr), f"Missing fitted attribute: {attr}" + + assert isinstance(clf.classes_, np.ndarray) and np.array_equal( + clf.classes_, np.unique(y) + ) + assert isinstance(clf.n_features_in_, int) and clf.n_features_in_ == X.shape[1] + assert isinstance(clf.loss_, (float, np.floating)) and clf.loss_ >= 0 + assert isinstance(clf.n_iter_, int) and 1 <= clf.n_iter_ <= 5 + assert isinstance(clf.n_layers_, int) and clf.n_layers_ == 3 + assert isinstance(clf.n_outputs_, int) and clf.n_outputs_ == len(np.unique(y)) - 1 + assert isinstance(clf.out_activation_, str) and clf.out_activation_ == "logistic" def test_nnpom_predict_invalid_input_raises_error(X, y): @@ -83,3 +116,10 @@ def test_nnpom_predict_invalid_input_raises_error(X, y): with pytest.raises(ValueError): classifier.predict([]) + + +def test_nnpom_predict_raises_if_not_fitted(X): + """Test that predict raises NotFittedError if called before fit.""" + classifier = NNPOM() + with pytest.raises(NotFittedError): + classifier.predict(X)