"""
Meta classifiers extend normal sklearn classifiers by combining multiple
predictions into one.

Example:
The data of a patient consists of multiple feature vectors gained from multiple heartbeats.
A meta classifier will predict the patients label respecting all his heartbeats.
"""

import numpy as np
from util import nested_to_matrix

from sklearn.kernel_approximation import RBFSampler

class MaximumLikelihoodClf:
    """
    Maximum likelihood classifier.
    """
    
    def __init__(self, sub_classifier):
        """
        sub_classifier = Underlying classifier. Must support `fit` and `predict_proba`.
        """
        self._sub_classifier = sub_classifier
        self._bounds = None
        self._fitted = False
    
    def fit(self, patients, targets):
        
        flat_fvs, idx = nested_to_matrix(patients)
        flat_targets = targets[idx]
        
        clf = self._sub_classifier
        clf.fit(flat_fvs, flat_targets)
        
        self._fitted = True
        
        probs = self.predict_proba(patients)
        classes = clf.classes_
        bounds = _probability_bounds(probs, classes, targets)
        self._bounds = bounds

    def predict_proba(self, patients):
        assert self._fitted, "MLC is not yet fitted."
        clf = self._sub_classifier
        
        def _predict_single_proba(patient):
            probs = clf.predict_proba(patient)
            mean_probs = np.mean(probs, axis = 0)
            return mean_probs
        
        probs = np.array(list(map(_predict_single_proba, patients)))
        
        return probs
        
    def predict(self, patients):
        assert self._fitted, "MLC is not yet fitted."
        clf = self._sub_classifier
        bounds = self._bounds
        predicted = np.array([_probability_vote(clf, patient, bounds) for patient in patients])
        
        return predicted

def _probability_bounds(predicted_probabilities, classes, targets):
    """ Find optimal decision bounds for _probability_vote.
    probabilities = n_targets * n_classes
    classes = The class labels of the probabilities.
    targets = The expected labels.
    """
    assert len(classes) == 2, "Only two classes supported."
    assert len(classes) == len(np.unique(targets)), "Invalid number of classes: %d"%len(classes)
    
    assert len(predicted_probabilities) == len(targets), \
        "Number of predictions and targets do not match: %d and %d."%(len(predicted_probabilities), len(targets))
    
    class1, class2 = classes
    
    w_uncertain = 0.5
    
    n1 = np.sum(targets == class1)
    n2 = np.sum(targets == class2)
    
    assert n1 > 0
    assert n2 > 0
    
    assert n1 + n2 == len(targets)
    
    probs_c1 = np.concatenate(predicted_probabilities[:, classes == class1])
    assert len(probs_c1) == len(targets)
    probs_c2 = np.concatenate(predicted_probabilities[:, classes == class2])
    assert len(probs_c2) == len(targets)
    
    def _find_single_bound(probabilities, weights):
        probs = probabilities
        sort_index = np.argsort(probs)
        
        # sort weights by matching probabilities
        weights = weights[sort_index]
        probs   = probs[sort_index]
        
        acc = np.add.accumulate(weights)
        max_index = np.argmax(acc)
        
        bound = probs[max_index]
        
        return bound
    
    probs = 1-probs_c1
    
    probs1 = probs
    weights1 = np.zeros(targets.shape)
    weights1[targets == class1]      = (1-w_uncertain) / n1
    weights1[targets == class2]      = -w_uncertain / n2
    bound1 = _find_single_bound(probs1, weights1)
    
    probs2 = -probs
    weights2 = np.zeros(targets.shape)
    weights2[targets == class1]      = -w_uncertain / n1
    weights2[targets == class2]      = (1-w_uncertain) / n2
    bound2 = -_find_single_bound(probs2, weights2)
    
    assert bound1 <= bound2, "Failed to find probability bounds."
        
    return (bound1, bound2)


def _probability_vote(proba_classifier, patient, bounds=(0.5, 0.5)):
    """
    Classify a patient by probabilities.
    
    proba_classifier = Trained LogisticRegression
    bounds = Decision bounds as calculated by `_probability_bounds()`
    """
    probabilities = proba_classifier.predict_proba(patient)
    classes = proba_classifier.classes_
    
    mean_probs = np.mean(probabilities, axis=0)
    
    #return classes[np.argmax(mean_probs)]
    
    p = mean_probs[1]
    
    if p < bounds[0]:
        return classes[0]
    elif p > bounds[1]:
        return classes[1]
    else:
        return 0    # uncertain




class MajorityClf:
    """
    Majority vote classifier.
    """
    
    def __init__(self, sub_classifier):
        """
        sub_classifier = Underlying classifier. Must support `fit` and `predict`.
        """
        self._sub_classifier = sub_classifier
        self._fitted = False
    
    def fit(self, patients, targets):
        
        flat_fvs, idx = nested_to_matrix(patients)
        flat_targets = targets[idx]
        
        clf = self._sub_classifier
        clf.fit(flat_fvs, flat_targets)
        
        self._fitted = True
        
        
    def predict(self, patients):
        assert self._fitted, "Classifier is not yet fitted."
        clf = self._sub_classifier
        predicted = np.array([_majority_vote(clf, patient) for patient in patients])
        
        return predicted

# counts a test person to a class if more of its FVs belong to that class
def _majority_vote(classifier, patient):

    classes = classifier.classes_
    predicted = classifier.predict(patient)
    
    votes = np.array([np.sum(predicted == c) for c in classes])
    majority = classes[np.argmax(votes)]
    
#     assert np.sum(predicted == majority) >= np.sum(predicted != majority)

    return majority
