# coding=utf-8
"""Whatable wrappers for scikit learn tools.

Using it is fairly simple and does not require changing anything that is already using sklearn,
but beware that we are monkey-patching sklearn BaseEstimator to add the what method.

Example
-------

>>> # Monkey-patch sklearn estimators
>>> whatamise_sklearn()
>>> # Use what() to retrieve the id of a model
>>> from sklearn.ensemble import RandomForestClassifier
>>> rfc = RandomForestClassifier(n_jobs=8, n_estimators=23)
>>> print(rfc.what().id())
rfc(bootstrap=True,class_weight=None,criterion='gini',max_depth=None,max_features='auto',max_leaf_nodes=None,min_samples_leaf=1,min_samples_split=2,min_weight_fraction_leaf=0.0,n_estimators=23,random_state=None,warm_start=False)
>>> print(rfc.what())
rfc(bootstrap=True,class_weight=None,criterion='gini',max_depth=None,max_features='auto',max_leaf_nodes=None,min_samples_leaf=1,min_samples_split=2,min_weight_fraction_leaf=0.0,n_estimators=23,n_jobs=8,oob_score=False,random_state=None,verbose=0,warm_start=False)

Implementation Notes
--------------------

scikit-learn, in the good tradition of machine learning libraries (e.g. good old weka)
already deals graciously with configurability. Almost all interesting tools in sklearn
(classifiers, regressors, clusterers, dimensionality-reducers and other preprocessors)
are classes inheriting from BaseEstimator:
  https://github.com/scikit-learn/scikit-learn/blob/master/sklearn/base.py

This is the key contract for these subclasses:
  "All estimators should specify all the parameters that can be set at the class level
   in their __init__ as explicit keyword arguments (no *args or **kwargs)."

This allows the sklearn folks to precisely define a protocol for configuring, configuration
retrieval and cloning (along with other niceties like standard control of deprecation).

It also makes really easy to add a what() method to scikit estimators.
"""

# Authors: Santi Villalba <sdvillal@gmail.com>
# Licence: BSD 3 clause

from __future__ import unicode_literals, absolute_import
import inspect
import warnings

from sklearn.base import BaseEstimator
from sklearn.cluster import KMeans
from sklearn.cross_decomposition import PLSRegression, PLSCanonical, CCA, PLSSVD
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor, ExtraTreesClassifier, ExtraTreesRegressor
from sklearn.ensemble.gradient_boosting import GradientBoostingClassifier, GradientBoostingRegressor
from sklearn.gaussian_process import GaussianProcess
from sklearn.linear_model import Ridge, Lasso, ElasticNet, Lars, OrthogonalMatchingPursuit, \
    BayesianRidge, ARDRegression, LogisticRegression, SGDClassifier, SGDRegressor, Perceptron, LassoLars, \
    LinearRegression
from sklearn.naive_bayes import GaussianNB, MultinomialNB, BernoulliNB
from sklearn.neighbors import KNeighborsClassifier, RadiusNeighborsClassifier, KNeighborsRegressor, \
    RadiusNeighborsRegressor, NearestCentroid
from sklearn.pipeline import FeatureUnion
from sklearn.preprocessing import Normalizer
from sklearn.svm import SVC, NuSVC, LinearSVC, SVR, NuSVR
from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor

from ..what import What
from ..misc import all_subclasses


# ----- Manually check which parameters are not part of the default id (WIP)

_LAST_SKLEARN_CHECKED = '1.5.1'

_SKLRegistry = {}


class _SklearnEstimatorID(object):
    """
    Stores information about sklearn short names, non_id_params, notes and possibly others.

    N.B. it is better to include a non-existing non_id_param
    (innocuous, can help to support several sklearn versions)
    than to leave out existing non_id_params.
    """

    def __init__(self,
                 skclass,
                 short_name,
                 non_id_params=(),
                 notes=None):
        self.skclass = skclass
        self.short_name = short_name if short_name is not None else skclass.__name__
        self.non_id_params = non_id_params
        self.notes = notes


def _a(skclass, nickname, non_id_params=(), notes=None):
    """Adds a class to the SKLRegistry."""
    _SKLRegistry[skclass.__name__] = _SklearnEstimatorID(skclass, nickname, non_id_params, notes)


# Ensembles
_a(RandomForestClassifier, 'rfc', ('verbose', 'n_jobs', 'oob_score'),
   'oob_score used to change the internal representation of the model (recheck)')
_a(RandomForestRegressor, 'rfr', ('verbose', 'n_jobs', 'oob_score'),
   'Same non-ids as RFC')
_a(ExtraTreesClassifier, 'etc', ('n_jobs', 'verbose', 'oob_score'))
_a(ExtraTreesRegressor, 'etr', ('n_jobs', 'verbose', 'oob_score'))
_a(GradientBoostingClassifier, 'gbc', ('verbose',))
_a(GradientBoostingRegressor, 'gbr', ('verbose',))

# GLMs
_a(Ridge, 'ridge', ('copy_X',))
_a(Lasso, 'lasso', ('copy_X',),
   'we need to take care of "precompute" when we pass the Gram matrix, should use a hash of the array')
_a(ElasticNet, 'elnet', ('copy_X',),
   'we need to take care of "precompute" when we pass the Gram matrix, should use a hash of the array')
_a(Lars, 'lars', ('verbose', 'copy_X', 'fit_path'),
   'we need to take care of "precompute" when we pass the Gram matrix, should use a hash of the array')
_a(LassoLars, 'lassolars', ('verbose', 'copy_X', 'fit_path'),
   'we need to take care of "precompute" when we pass the Gram matrix, should use a hash of the array')
_a(OrthogonalMatchingPursuit, 'omp', ('copy_X', 'copy_Xy', 'copy_Gram'),
   'we need to take care of "precompute" when we pass the Gram matrix, should use a hash of the array')
_a(BayesianRidge, 'bayridge', ('copy_X', 'verbose'))
_a(ARDRegression, 'ardr', ('copy_X', 'verbose'))
_a(LogisticRegression, 'logreg')
_a(SGDClassifier, 'sgdc', ('verbose', 'n_jobs'))
_a(SGDRegressor, 'sgdr', ('verbose',))
_a(Perceptron, 'perceptron', ('verbose', 'n_jobs'))
_a(LinearRegression, 'lr', ('copy_X',))

# SVMs
_a(SVC, 'svc', ('cache_size', 'verbose'))
_a(NuSVC, 'nusvc', ('cache_size', 'verbose'))
_a(LinearSVC, 'linsvc', ('verbose',))
_a(SVR, 'svr', ('cache_size', 'verbose'))
_a(NuSVR, 'nusvr', ('cache_size', 'verbose'))

# NaiveBayes
_a(GaussianNB, 'gnb')
_a(MultinomialNB, 'mnb')
_a(BernoulliNB, 'bnb')

# Decision Trees
_a(DecisionTreeClassifier, 'dtc')
_a(DecisionTreeRegressor, 'dtr')

# Nearest Neighbours
_a(KNeighborsClassifier, 'knc', ('warn_on_equidistant',))
_a(RadiusNeighborsClassifier, 'rnc', ('outlier_label',),
   'outlier_label is questionable, but it won\'t change the model')
_a(KNeighborsRegressor, 'knr', ('warn_on_equidistant',))
_a(RadiusNeighborsRegressor, 'rnr')
_a(NearestCentroid, 'nc')

# Partial Least Squares
_a(PLSRegression, 'plsr', ('copy',))
_a(PLSCanonical, 'plscan', ('copy',))
_a(CCA, 'cca', ('copy',))
_a(PLSSVD, 'plssvd')

# Gaussian Processes
_a(GaussianProcess, 'gp', ('storage_mode', 'verbose'))

# Clusterers
_a(KMeans, None, ('n_jobs', 'copy_x'))

# Preprocessing
_a(Normalizer, None, ('copy',))

# Pipelines
_a(FeatureUnion, 'f_union', ('n_jobs',))


# Make the invese map: short_name -> long_name
_SKLShort2Long = dict((v.short_name, k) for k, v in _SKLRegistry.items())


# ----- Monkey-patching for whatability


def _what_for_sklearn(x):
    """Returns a What configuration for the scikit learn estimator x."""
    name = x.__class__.__name__
    configuration_dict = x.get_params(deep=False)   # N.B. we take care of recursing ourselves
    pinfo = _SKLRegistry.get(name, None)
    if pinfo is not None:
        return What(pinfo.short_name,
                    conf=configuration_dict,
                    non_id_keys=pinfo.non_id_params)
    return What(name, conf=configuration_dict)


def whatamise_sklearn(check=False, log=False):
    """Monkey-patches sklearn.base.BaseEstimator to make sklearn estimators whatable.

    Parameters
    ----------
    check: boolean, default True
        If True, call check_all_monkeypatched() after getting right BaseEstimator.what
    """
    if not hasattr(BaseEstimator, 'what'):
        BaseEstimator.what = _what_for_sklearn
    if check:
        _check_all_monkeypatched()
    if log:
        print('Scikit-Learn estimators now are whatable')


def _check_all_monkeypatched():
    """Double-checks that instances sklearn estimators have acquired the proper "what" method.
    Raises an assertion error if it is not the case.
    """

    # Make sure we have added what to sklearn stuff
    whatamise_sklearn(check=False)

    # Trick to force python to populate part of the BaseEstimator hierarchy
    from sklearn.ensemble.forest import RandomForestClassifier
    assert BaseEstimator.__subclasscheck__(RandomForestClassifier)
    from sklearn.cluster import KMeans
    assert BaseEstimator.__subclasscheck__(KMeans)
    from sklearn.feature_extraction import DictVectorizer
    assert BaseEstimator.__subclasscheck__(DictVectorizer)
    from sklearn.decomposition import KernelPCA
    assert BaseEstimator.__subclasscheck__(KernelPCA)

    with warnings.catch_warnings():
        warnings.simplefilter('ignore')
        for cls in all_subclasses(BaseEstimator):
            if not inspect.isabstract(cls):
                try:
                    obj = cls()
                    assert hasattr(obj, 'what'), cls.__name__
                    assert isinstance(obj.what(), What), cls.__name__
                except TypeError:
                    pass
    return True


####
#
# Notes:
#  - As usual, when rng (random_state) is a None or a custom rng, identity is not really defined.
#    Maybe we could warn to always use an int random_state when this is detected (in what_for_sklearn).
#
#  - This is too aggressive on patching, but creating a lighter version by getting a plugin to whatareyou
#    that realizes about sklearn classes and applies ignores wisely is very easy to do.
#
####
