diff --git a/multiview_platform/MonoMultiViewClassifiers/MonoviewClassifiers/MinCQ.py b/multiview_platform/MonoMultiViewClassifiers/MonoviewClassifiers/MinCQ.py index 7edf2bf666916978bd778e94b17826cd9b99bbe6..73133a3e093bf17d4ced59ed01c60efba9e76156 100644 --- a/multiview_platform/MonoMultiViewClassifiers/MonoviewClassifiers/MinCQ.py +++ b/multiview_platform/MonoMultiViewClassifiers/MonoviewClassifiers/MinCQ.py @@ -54,7 +54,7 @@ class MinCqLearner(BaseEstimator, ClassifierMixin): Kernel coefficient for 'rbf' and 'poly'. If gamma is 0.0 then 1/n_features will be used instead. """ - def __init__(self, mu, voters_type, n_stumps_per_attribute=10, kernel='rbf', degree=3, gamma=0.0): + def __init__(self, mu, voters_type, n_stumps_per_attribute=10, kernel='rbf', degree=3, gamma=0.0, self_complemented=True): assert mu > 0 and mu <= 1, "MinCqLearner: mu parameter must be in (0, 1]" self.mu = mu self.voters_type = voters_type @@ -63,6 +63,7 @@ class MinCqLearner(BaseEstimator, ClassifierMixin): self.degree = degree self.gamma = gamma self.log = False + self.self_complemented = self_complemented self.majority_vote = None self.qp = None @@ -114,7 +115,7 @@ class MinCqLearner(BaseEstimator, ClassifierMixin): elif self.kernel == 'rbf': voters_generator = KernelVotersGenerator(rbf_kernel, gamma=gamma) - voters = voters_generator.generate(X, y_reworked) + voters = voters_generator.generate(X, y_reworked, self_complemented=self.self_complemented) if self.log: logging.info("MinCq training started...") @@ -524,13 +525,14 @@ class KernelVotersGenerator(VotersGenerator): class MinCQ(MinCqLearner, BaseMonoviewClassifier): - def __init__(self, random_state=None, mu=0.01, epsilon=1e-06, **kwargs): + def __init__(self, random_state=None, mu=0.01, self_complemented=True , **kwargs): super(MinCQ, self).__init__(mu=mu, voters_type='stumps', - n_stumps_per_attribute =10 + n_stumps_per_attribute =10, + self_complemented=self_complemented ) self.param_names = ["mu"] - self.distribs = [CustomUniform(loc=0.5, state=1.0, multiplier="e-"), + self.distribs = [CustomUniform(loc=0.5, state=2.0, multiplier="e-"), ] self.classed_params = [] self.weird_strings = {} diff --git a/multiview_platform/MonoMultiViewClassifiers/MonoviewClassifiers/MinCQGraalpy.py b/multiview_platform/MonoMultiViewClassifiers/MonoviewClassifiers/MinCQGraalpy.py new file mode 100644 index 0000000000000000000000000000000000000000..b638c6fbd47584dc1894c85fe84db83b77b7c9c8 --- /dev/null +++ b/multiview_platform/MonoMultiViewClassifiers/MonoviewClassifiers/MinCQGraalpy.py @@ -0,0 +1,393 @@ +# -*- coding: utf-8 -*- +"""MinCq algorithm. + +Related papers: +[1] From PAC-Bayes Bounds to Quadratic Programs for Majority Votes (Laviolette et al., 2011) +[2] Risk Bounds for the Majority Vote: From a PAC-Bayesian Analysis to a Learning Algorithm (Germain et al., 2015) + +""" +from __future__ import print_function, division, absolute_import +import logging +from operator import xor + +import numpy as np +from scipy.linalg import sqrtm +from scipy.spatial.distance import pdist, squareform +from sklearn.metrics.pairwise import rbf_kernel +from sklearn.utils.validation import check_X_y +from sklearn.ensemble import VotingClassifier +from sklearn.manifold import SpectralEmbedding +from sklearn.utils.graph import graph_laplacian +from sklearn.preprocessing import LabelEncoder + + +from ..Monoview.Additions.BoostUtils import ConvexProgram, StumpsClassifiersGenerator +from ..Monoview.MonoviewUtils import BaseMonoviewClassifier, CustomUniform +from ..Metrics import zero_one_loss + +# logger = logging.getLogger('MinCq') + +class MinCqClassifier(VotingClassifier): + """ + Base MinCq algorithm learner. See [1, 2]. + This version is an attempt of creating a more general version of MinCq, that handles multiclass classfication. + For binary classification, use RegularizedMinCqClassifer. + + Parameters + ---------- + mu : float + The fixed value of the first moment of the margin. + + """ + def __init__(self, estimators_generator=None, estimators=None, mu=0.001, omega=0.5, use_binary=False, zeta=0, gamma=1, n_neighbors=5): + if estimators is None: + estimators = [] + + super().__init__(estimators=estimators, voting='soft') + self.estimators_generator = estimators_generator + self.mu = mu + self.omega = omega + self.use_binary = use_binary + self.zeta = zeta + self.gamma = gamma + self.n_neighbors = n_neighbors + + def fit(self, X, y): + """Fit the estimators and learn the weights. + + Parameters + ---------- + X : array-like, shape = [n_samples, n_features] + Training vectors, where n_samples is the number of samples and + n_features is the number of features. + y : array-like, shape = [n_samples] + Target values. If y is a masked-array (numpy.ma), the masked values are unlabeled examples. + + Returns + ------- + self : object + + """ + # Validations + assert 0 < self.mu <= 1, "MinCqClassifier: mu parameter must be in (0, 1]" + assert xor(bool(self.estimators_generator), bool(self.estimators)), "MinCqClassifier: exactly one of estimator_generator or estimators must be used." + X, y = check_X_y(X, y) + + # Fit the estimators using VotingClassifier's fit method. This will also fit a LabelEncoder that can be + # used to "normalize" labels (0, 1, 2, ...). In the case of binary classification, the two classes will be 0 and 1. + # First, ensure that the weights are reset to None (as cloning a VotingClassifier keeps the weights) + self.weights = None + # TODO: Ensure estimators can deal with masked arrays + + # If we use an estimator generator, use the data-dependant estimator generator to generate them, and fit again. + if self.estimators: + super().fit(X, y) + + else: + self.le_ = LabelEncoder() + self.le_.fit(y) + + if isinstance(y, np.ma.MaskedArray): + transformed_y = np.ma.MaskedArray(self.le_.transform(y), y.mask) + else: + transformed_y = self.le_.transform(y) + + self.estimators_generator.fit(X, transformed_y) + self.estimators = [('ds{}'.format(i), estimator) for i, estimator in enumerate(self.estimators_generator.estimators_)] + super().fit(X, y) + + # We clean the estimators attribute (as we do not want it to be cloned later) + # self.estimators_ = [] + + # logger.info("Training started...") + # logger.info("Training dataset shape: {}".format(str(np.shape(X)))) + # logger.info("Number of voters: {}".format(len(self.estimators_))) + + # Preparation and resolution of the quadratic program + # logger.info("Preparing and solving QP...") + self.weights = self._solve(X, y) + + return self + + # def evaluate_metrics(self, X, y, metrics_list=None, functions_list=None): + # if metrics_list is None: + # metrics_list = [zero_one_loss] + # + # if functions_list is None: + # functions_list = [] + # else: + # raise NotImplementedError + # + # # Predict, evaluate metrics. + # predictions = self.predict(X) + # metrics_results = {metric.__name__: metric(y, predictions) for metric in metrics_list} + # + # metrics_dataframe = ResultsDataFrame([metrics_results]) + # return metrics_dataframe + + def _binary_classification_matrix(self, X): + probas = self.transform(X) + predicted_labels = np.argmax(probas, axis=2) + predicted_labels[predicted_labels == 0] = -1 + values = np.max(probas, axis=2) + return (predicted_labels * values).T + + def _multiclass_classification_matrix(self, X, y): + probas = self.transform(X).swapaxes(0, 1) + matrix = probas[np.arange(probas.shape[0]), :, y] + + return (matrix - self.omega) + + def _solve(self, X, y): + y = self.le_.transform(y) + + if self.use_binary: + assert len(self.le_.classes_) == 2 + + # TODO: Review the number of labeled examples when adding back the support for transductive learning. + classification_matrix = self._binary_classification_matrix(X) + + # We use {-1, 1} labels. + binary_labels = np.copy(y) + binary_labels[y == 0] = -1 + + multi_matrix = binary_labels.reshape((len(binary_labels), 1)) * classification_matrix + + else: + multi_matrix = self._multiclass_classification_matrix(X, y) + + n_examples, n_voters = np.shape(multi_matrix) + ftf = 1.0 / n_examples * multi_matrix.T.dot(multi_matrix) + yf = np.mean(multi_matrix, axis=0) + + # Objective function. + objective_matrix = 2 * ftf + objective_vector = None + + # Equality constraints (first moment of the margin equal to mu, Q sums to one) + equality_matrix = np.vstack((yf.reshape((1, n_voters)), np.ones((1, n_voters)))) + equality_vector = np.array([self.mu, 1.0]) + + # Lower and upper bounds, no quasi-uniformity. + lower_bound = 0.0 + # TODO: In the case of binary classification, no upper bound will give + # bad results. Using 1/n works, as it brings back the l_infinity + # regularization normally given by the quasi-uniformity constraint. + # upper_bound = 2.0/n_voters + upper_bound = None + + weights = self._solve_qp(objective_matrix, objective_vector, equality_matrix, equality_vector, lower_bound, upper_bound) + + # Keep learning information for further use. + self.learner_info_ = {} + + # We count the number of non-zero weights, including the implicit voters. + # TODO: Verify how we define non-zero weights here, could be if the weight is near 1/2n. + n_nonzero_weights = np.sum(np.asarray(weights) > 1e-12) + n_nonzero_weights += np.sum(np.asarray(weights) < 1.0 / len(self.estimators_) - 1e-12) + self.learner_info_.update(n_nonzero_weights=n_nonzero_weights) + + return weights + + def _solve_qp(self, objective_matrix, objective_vector, equality_matrix, equality_vector, lower_bound, upper_bound): + try: + qp = ConvexProgram() + qp.quadratic_func, qp.linear_func = objective_matrix, objective_vector + qp.add_equality_constraints(equality_matrix, equality_vector) + qp.add_lower_bound(lower_bound) + qp.add_upper_bound(upper_bound) + return qp.solve() + + except Exception: + # logger.warning("Error while solving the quadratic program.") + raise + + +class RegularizedBinaryMinCqClassifier(MinCqClassifier): + """MinCq, version published in [1] and [2], where the regularization comes from the enforced quasi-uniformity + of the posterior distributino on the symmetric hypothesis space. This version only works with {-1, 1} labels. + + [1] From PAC-Bayes Bounds to Quadratic Programs for Majority Votes (Laviolette et al., 2011) + [2] Risk Bounds for the Majority Vote: From a PAC-Bayesian Analysis to a Learning Algorithm (Germain et al., 2015) + + """ + def fit(self, X, y): + # We first fit and learn the weights. + super().fit(X, y) + + # Validations + if isinstance(y, np.ma.MaskedArray): + assert len(self.classes_[np.where(np.logical_not(self.classes_.mask))]) == 2, "RegularizedBinaryMinCqClassifier: only supports binary classification." + else: + assert len(self.classes_), "RegularizedBinaryMinCqClassifier: only supports binary classification." + + # Then we "reverse" the negative weights and their associated voter's output. + for i, weight in enumerate(self.weights): + if weight < 0: + # logger.debug("Reversing decision of a binary voter") + self.weights[i] *= -1 + self.estimators_[i].reverse_decision() + + return self + + def _solve(self, X, y): + if isinstance(y, np.ma.MaskedArray): + y = np.ma.MaskedArray(self.le_.transform(y), y.mask) + else: + y = self.le_.transform(y) + + classification_matrix = self._binary_classification_matrix(X) + n_examples, n_voters = np.shape(classification_matrix) + + if self.zeta == 0: + ftf = classification_matrix.T.dot(classification_matrix) + else: + I = np.eye(n_examples) + L = build_laplacian(X, n_neighbors=self.n_neighbors) + ftf = classification_matrix.T.dot(I + (self.zeta / n_examples) * L).dot(classification_matrix) + + # We use {-1, 1} labels. + binary_labels = np.ma.copy(y) + binary_labels[np.ma.where(y == 0)] = -1 + + # Objective function. + ftf_mean = np.mean(ftf, axis=1) + objective_matrix = 2.0 / n_examples * ftf + objective_vector = -1.0 / n_examples * ftf_mean.T + + # Equality constraint: first moment of the margin fixed to mu, only using labeled examples. + if isinstance(y, np.ma.MaskedArray): + labeled = np.where(np.logical_not(y.mask))[0] + binary_labels = binary_labels[labeled] + else: + labeled = range(len(y)) + + yf = binary_labels.T.dot(classification_matrix[labeled]) + yf_mean = np.mean(yf) + equality_matrix = 2.0 / len(labeled) * yf + equality_vector = self.mu + 1.0 / len(labeled) * yf_mean + + # Lower and upper bounds (quasi-uniformity constraints) + lower_bound = 0.0 + upper_bound = 1.0 / n_voters + + weights = self._solve_qp(objective_matrix, objective_vector, equality_matrix, equality_vector, lower_bound, upper_bound) + + # Keep learning information for further use. + self.learner_info_ = {} + + # We count the number of non-zero weights, including the implicit voters. + # TODO: Verify how we define non-zero weights here, could be if the weight is near 1/2n. + n_nonzero_weights = np.sum(np.asarray(weights) > 1e-12) + n_nonzero_weights += np.sum(np.asarray(weights) < 1.0 / len(self.estimators_) - 1e-12) + self.learner_info_.update(n_nonzero_weights=n_nonzero_weights) + + # Conversion of the weights of the n first voters to weights on the implicit 2n voters. + # See Section 7.1 of [2] for an explanation. + return np.array([2 * q - 1.0 / len(self.estimators_) for q in weights]) + + # def evaluate_metrics(self, X, y, metrics_list=None, functions_list=None): + # if metrics_list is None: + # metrics_list = [zero_one_loss] + # + # if functions_list is None: + # functions_list = [] + # + # # Transductive setting: we only predict the X for labeled y + # if isinstance(y, np.ma.MaskedArray): + # labeled = np.where(np.logical_not(y.mask))[0] + # X = np.array(X[labeled]) + # y = np.array(y[labeled]) + # + # # Predict, evaluate metrics. + # predictions = self.predict(X) + # metrics_results = {metric.__name__: metric(y, predictions) for metric in metrics_list} + # + # # TODO: Repair in the case of non-{-1, 1} labels. + # assert set(y) == {-1, 1} + # classification_matrix = self._binary_classification_matrix(X) + # + # for function in functions_list: + # metrics_results[function.__name__] = function(classification_matrix, y, self.weights) + # + # metrics_dataframe = ResultsDataFrame([metrics_results]) + # return metrics_dataframe + + +def build_laplacian(X, n_neighbors=None): + clf = SpectralEmbedding(n_neighbors=n_neighbors) + clf.fit(X) + w = clf.affinity_matrix_ + laplacian = graph_laplacian(w, normed=True) + return laplacian + + +class MinCQGraalpy(RegularizedBinaryMinCqClassifier, BaseMonoviewClassifier): + + def __init__(self, random_state=None, mu=0.01, self_complemented=True, n_stumps_per_attribute=10 , **kwargs): + super(MinCQGraalpy, self).__init__(mu=mu, + estimators_generator=StumpsClassifiersGenerator(n_stumps_per_attribute=n_stumps_per_attribute, self_complemented=self_complemented), + ) + self.param_names = ["mu"] + self.distribs = [CustomUniform(loc=0.5, state=2.0, multiplier="e-"), + ] + self.classed_params = [] + self.weird_strings = {} + if "nbCores" not in kwargs: + self.nbCores = 1 + else: + self.nbCores = kwargs["nbCores"] + + def canProbas(self): + """Used to know if the classifier can return label probabilities""" + return True + + def getInterpret(self, directory, y_test): + interpret_string = "" + # interpret_string += "Train C_bound value : "+str(self.cbound_train) + # y_rework = np.copy(y_test) + # y_rework[np.where(y_rework==0)] = -1 + # interpret_string += "\n Test c_bound value : "+str(self.majority_vote.cbound_value(self.x_test, y_rework)) + return interpret_string + + def get_name_for_fusion(self): + return "MCG" + + +def formatCmdArgs(args): + """Used to format kwargs for the parsed args""" + kwargsDict = {"mu":args.MCG_mu, + "n_stumps_per_attribute":args.MCG_stumps} + return kwargsDict + + +def paramsToSet(nIter, randomState): + """Used for weighted linear early fusion to generate random search sets""" + paramsSet = [] + for _ in range(nIter): + paramsSet.append({}) + return paramsSet + + +# if __name__ == '__main__': +# # Example usage. +# from sklearn.datasets import load_iris +# from sklearn.cross_validation import train_test_split +# from graalpy.utils.majority_vote import StumpsClassifiersGenerator +# +# # Load data, change {0, 1, 2} labels to {-1, 1} +# iris = load_iris() +# iris.target[np.where(iris.target == 0)] = -1 +# iris.target[np.where(iris.target == 2)] = 1 +# x_train, x_test, y_train, y_test = train_test_split(iris.data, iris.target, random_state=42) +# +# # Fit MinCq +# clf = RegularizedBinaryMinCqClassifier(estimators_generator=StumpsClassifiersGenerator()) +# clf.fit(x_train, y_train) +# +# # Compare the best score of individual classifiers versus the score of the learned majority vote. +# print("Best training risk of individual voters: {:.4f}".format(1 - max([e.score(x_train, y_train) for e in clf.estimators_]))) +# print("Training risk of the majority vote outputted by MinCq: {:.4f}".format(1 - clf.score(x_train, y_train))) +# print() +# print("Best testing risk of individual voters: {:.4f}".format(1 - max([e.score(x_test, y_test) for e in clf.estimators_]))) +# print("Testing risk of the majority vote outputted by MinCq: {:.4f}".format(1 - clf.score(x_test, y_test))) diff --git a/multiview_platform/MonoMultiViewClassifiers/utils/execution.py b/multiview_platform/MonoMultiViewClassifiers/utils/execution.py index 43b2e1d8f4dbfe69187658913d810b2b2bd2ad19..9a3a91d49af6345c2c0254d404cee54918a6f8b0 100644 --- a/multiview_platform/MonoMultiViewClassifiers/utils/execution.py +++ b/multiview_platform/MonoMultiViewClassifiers/utils/execution.py @@ -138,7 +138,7 @@ def parseTheArgs(arguments): groupAdaboostGraalpy.add_argument('--AdG_n_iter', metavar='INT', type=int, action='store', help='Number of estimators', - default=2) + default=100) groupAdaboostGraalpy.add_argument('--AdG_stumps', metavar='INT', type=int, action='store', help='Number of stumps inthe pregenerated dataset', @@ -182,7 +182,7 @@ def parseTheArgs(arguments): groupCQBoost.add_argument('--CQB_mu', metavar='FLOAT', type=float, action='store', help='Set the mu parameter for CQBoost', default=0.001) groupCQBoost.add_argument('--CQB_epsilon', metavar='FLOAT', type=float, action='store', - help='Set the epsilon parameter for CQBoost', default=1e-08) + help='Set the epsilon parameter for CQBoost', default=1e-06) groupCQBoost.add_argument('--CQB_stumps', metavar='INT', type=int, action='store', help='Set the number of stumps for CQBoost', @@ -190,7 +190,7 @@ def parseTheArgs(arguments): groupCQBoostv2 = parser.add_argument_group('CQBoostv2 arguments') groupCQBoostv2.add_argument('--CQB2_mu', metavar='FLOAT', type=float, action='store', - help='Set the mu parameter for CQBoostv2', default=0.001) + help='Set the mu parameter for CQBoostv2', default=0.002) groupCQBoostv2.add_argument('--CQB2_epsilon', metavar='FLOAT', type=float, action='store', help='Set the epsilon parameter for CQBoostv2', default=1e-08) @@ -238,17 +238,26 @@ def parseTheArgs(arguments): help='Set the n_estimators_parameter for Gradient Boosting', default=100) - groupMinCQ = parser.add_argument_group( - 'MinCQ arguments') - groupMinCQ.add_argument('--MCQ_mu', metavar='float', type=int, - action='store', - help='Set the mu_parameter for MinCQ', - default=1e-3) + groupMinCQ = parser.add_argument_group('MinCQ arguments') + groupMinCQ.add_argument('--MCQ_mu', metavar='FLOAT', type=float, + action='store', + help='Set the mu_parameter for MinCQ', + default=0.05) groupMinCQ.add_argument('--MCQ_stumps', metavar='INT', type=int, action='store', help='Set the n_stumps_per_attribute parameter for MinCQ', default=1) + groupMinCQGraalpy = parser.add_argument_group('MinCQGraalpy arguments') + groupMinCQGraalpy.add_argument('--MCG_mu', metavar='FLOAT', type=float, + action='store', + help='Set the mu_parameter for MinCQGraalpy', + default=0.05) + groupMinCQGraalpy.add_argument('--MCG_stumps', metavar='INT', type=int, + action='store', + help='Set the n_stumps_per_attribute parameter for MinCQGraalpy', + default=1) + groupQarBoostv3 = parser.add_argument_group('QarBoostv3 arguments') groupQarBoostv3.add_argument('--QarB3_mu', metavar='FLOAT', type=float, action='store', help='Set the mu parameter for QarBoostv3', default=0.001)