The Wayback Machine - https://web.archive.org/web/20210104021855/https://github.com/scikit-learn/scikit-learn/pull/16079
Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] sample props (proposal 4) #16079

Open
wants to merge 71 commits into
base: master
from

Conversation

@adrinjalali
Copy link
Member

@adrinjalali adrinjalali commented Jan 9, 2020

Taking a stab at proposal 4 of the sample prop proposal (SLEP006 Routing sample-aligned meta-data).

Clear WIP, just putting it here for now for us to see how it'd look.

The first change I've made compared to the proposal is that the dict
storing the required props, includes a key as which method requires
which props. This way score and fit and predict can all have different
requirements.

You can see SLEP examples implemented in test_props.py as test_slep_case* functions.

Closes #9566, #15425, #3524, #15425,
Fixes #4497, #7136, #13432, #4632, #7646 (add test), #8127 (add test), #8158,
Enables #6322,
TODO:
#12052, #8710, #15282, #2630, #8950, #11429, #15282, #18028

@jnothman
Copy link
Member

@jnothman jnothman commented Jan 9, 2020

@adrinjalali
Copy link
Member Author

@adrinjalali adrinjalali commented Jan 10, 2020

Here's an example with a pipeline. @jnothman would you please check if it's [almost] what you had in mind?

@NicolasHug you were interested in this one as well IIRC.

from sklearn.model_selection import GroupKFold, cross_validate
from sklearn.feature_selection import SelectKBest
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegressionCV
from sklearn.metrics import accuracy_score
from sklearn.metrics import make_scorer
import numpy as np
from sklearn.datasets import make_classification
X, y = make_classification()
sw = np.random.rand(len(X))
sw2 = [5, 6]
brand = ['my brand']
from sklearn.base import BaseEstimator, ClassifierMixin, TransformerMixin
from sklearn.utils.validation import _validate_required_props
from sklearn.svm import SVC

class MyEst(ClassifierMixin, BaseEstimator):
    def __init__(self):
        self._props_request = {'fit': ['sample_weight', 'brand']}

    def fit(self, X, y, **fit_params):
        _validate_required_props(self, fit_params, 'fit')
        self._estimator = SVC().fit(X, y, fit_params['sample_weight'])
        print("{} recieved params {} of lengths {}".format(
            self.__class__.__name__,
            list(fit_params.keys()), [len(v) for v in fit_params.values()]))

class MyTrs(TransformerMixin, BaseEstimator):
    def __init__(self):
        self._props_request = {'fit': ['sample_weight']}

    def fit(self, X, y=None, **fit_params):
        _validate_required_props(self, fit_params, 'fit')
        self._estimator = SelectKBest().fit(X, y)
        print("{} recieved params {} of lengths {}".format(
            self.__class__.__name__,
            list(fit_params.keys()), [len(v) for v in fit_params.values()]))
        return self

    def transform(self, X, y=None):
        return self._estimator.transform(X)
clf = make_pipeline(MyTrs(), MyEst())
clf.fit(X, y, sample_weight=sw, brand=brand)
MyTrs recieved params ['sample_weight'] of lengths [100]
MyEst recieved params ['sample_weight', 'brand'] of lengths [100, 1]

Pipeline(memory=None, steps=[('mytrs', MyTrs()), ('myest', MyEst())],
         verbose=False)
trs = MyTrs().set_props_request(
    None).set_props_request(
    {'fit': {'new_param': 'my_sw'}})
clf = make_pipeline(trs, MyEst())
clf.fit(X, y, sample_weight=sw, brand=brand, my_sw=sw2)
MyTrs recieved params ['new_param'] of lengths [2]
MyEst recieved params ['sample_weight', 'brand'] of lengths [100, 1]

Pipeline(memory=None, steps=[('mytrs', MyTrs()), ('myest', MyEst())],
         verbose=False)
clf.fit(X, y, brand=brand)
---------------------------------------------------------------------------

ValueError                                Traceback (most recent call last)

<ipython-input-6-b2a96948c309> in <module>
----> 1 clf.fit(X, y, brand=brand)


~/Projects/sklearn/scikit-learn/sklearn/pipeline.py in fit(self, X, y, **fit_params)
    364             This estimator
    365         """
--> 366         Xt, fit_params = self._fit(X, y, **fit_params)
    367         with _print_elapsed_time('Pipeline',
    368                                  self._log_message(len(self.steps) - 1)):


~/Projects/sklearn/scikit-learn/sklearn/pipeline.py in _fit(self, X, y, **fit_params)
    288         fit_transform_one_cached = memory.cache(_fit_transform_one)
    289 
--> 290         _validate_required_props(self, fit_params, 'fit')
    291 
    292         for (step_idx,


~/Projects/sklearn/scikit-learn/sklearn/utils/validation.py in _validate_required_props(obj, given_props, method)
   1337     given_props = set(given_props.keys())
   1338     if required_props != given_props:
-> 1339         raise ValueError("Requested properties are: {}, but {} "
   1340                          "provided".format(list(required_props),
   1341                                            list(given_props)))


ValueError: Requested properties are: ['brand', 'my_sw', 'sample_weight'], but ['brand'] provided
@jnothman
Copy link
Member

@jnothman jnothman commented Jan 13, 2020

That example looks nice! I think I had not envisaged that all requested props were required, i.e. an estimator could request sample_weight by default but only receive it if available. What are your thoughts on that?

What I'm not sure about is whether we need to specify that the request is for fit. I suppose that makes things more flexible.

I have looked at your modification to clone. Okay. But I've not yet looked at much else

@adrinjalali
Copy link
Member Author

@adrinjalali adrinjalali commented Feb 10, 2020

I think I had not envisaged that all requested props were required, i.e. an estimator could request sample_weight by default but only receive it if available. What are your thoughts on that?

I also first thought of having it as "please give me that prop if you have it" rather than "I really need it". The reason I have it as a mandatory requirement, is that it kinda removes the chances of a meta estimator not passing a required prop by mistake. My gut feeling was that it removes some of those bugs.

What I'm not sure about is whether we need to specify that the request is for fit. I suppose that makes things more flexible.

I think your other proposals had a more flexible system for routing the props around. Especially when it comes to fit/score/split distinction. Also, I thought this makes it easier to have some props going to transform/predict as well.

@jnothman
Copy link
Member

@jnothman jnothman commented Feb 11, 2020

The reason I have it as a mandatory requirement, is that it kinda removes the chances of a meta estimator not passing a required prop by mistake. My gut feeling was that it removes some of those bugs.

But this would be a mistake in meta-estimator implementation which should not be the user's problem?

adrinjalali added 5 commits Feb 20, 2020
@adriangb
Copy link

@adriangb adriangb commented Apr 29, 2020

This looks good!

Edit: previous comment was irrelevant after I finally read all of #4497 and #SLEP006. My initial conception (and hacky implementation in personal project) looked very similar to the single argument, no routing approach.

Now for some comments (sorry if these are still uninformed):

  • single argument vs. kwargs: I feel strongly that the single argument approach is best.
  • routing vs. blind passing of all: I see pros and cons to each, but I think it should be possible to implement in a non-exclusive manner. Ex, if the key starts with route_ it gets routed (however that ends up working) and if not it always gets passed. That would also allow for the simpler behavior to be implemented immediately and the routing to be done separately once that basic infrastructure is in place.
  • unexpected behavior changes: very good points were raised in #4497 regarding changes in behavior without changes in data. I think that is definitely a concern, but I cannot immediately think of a way around that.

I guess my main question/point is: can this be done in two phases? Phase 1 would be implementing the basic infrastructure (i.e. changing a lot of signatures thought the API) so that sample_props gets passed correctly. Changes would also have to be made to fold in sample_weight and other existing sample properties. Phase 2 would be routing, requesting/requiring, etc.

That said, how can I be of use to get this done?

Copy link
Member

@jnothman jnothman left a comment

This is good, but it needs work :)

sklearn/base.py Outdated Show resolved Hide resolved
sklearn/base.py Outdated Show resolved Hide resolved
sklearn/base.py Outdated Show resolved Hide resolved
sklearn/base.py Outdated Show resolved Hide resolved
sklearn/base.py Outdated Show resolved Hide resolved
@@ -257,6 +258,24 @@ def _log_message(self, step_idx):
len(self.steps),
name)

def get_props_request(self):

This comment has been minimized.

@jnothman

jnothman Jun 22, 2020
Member

There will be cases where the meta-estimator requests a prop, such as sample_weight regardless of whether its descendant estimators also request props.

The implementation here implies that the caller of get_props_request always wants the "deep" props of its descendants. Do we have use cases that would want us to be able to inspect only the locally consumed props?

This comment has been minimized.

@adrinjalali

adrinjalali Jul 3, 2020
Author Member

I can't think of such a case off the top of my head, but if we do at some point, we can add a deep parameter here.

sklearn/base.py Outdated Show resolved Hide resolved
Copy link
Member

@jnothman jnothman left a comment

Happy to work through this more or assist with the changes

sklearn/base.py Outdated Show resolved Hide resolved
@jnothman
Copy link
Member

@jnothman jnothman commented Jul 2, 2020

I think the consensus at the last meeting was to rename the prop request to a metadata request.

How is this going? Is there something I can help with here?

adrinjalali added 6 commits Jul 3, 2020
Copy link
Member Author

@adrinjalali adrinjalali left a comment

The code is now a bit simpler, tests on sklearn.metrics pass, and test_props.py also passes.

I need to support the old behavior in grid search and pipeline somehow as well.

sklearn/base.py Outdated Show resolved Hide resolved
sklearn/base.py Outdated Show resolved Hide resolved
@@ -257,6 +258,24 @@ def _log_message(self, step_idx):
len(self.steps),
name)

def get_props_request(self):

This comment has been minimized.

@adrinjalali

adrinjalali Jul 3, 2020
Author Member

I can't think of such a case off the top of my head, but if we do at some point, we can add a deep parameter here.

sklearn/base.py Outdated Show resolved Hide resolved
sklearn/metrics/_scorer.py Outdated Show resolved Hide resolved
adrinjalali added 14 commits Nov 26, 2020
@adrinjalali
Copy link
Member Author

@adrinjalali adrinjalali commented Nov 27, 2020

This is getting there, at least tests pass w/o -Werror::FutureWarning. @amueller wanna have a look?

adrinjalali added 5 commits Nov 27, 2020
@adrinjalali
Copy link
Member Author

@adrinjalali adrinjalali commented Nov 28, 2020

Not sure if we ever made a decision on it, but the PR as is, has scorers request sample_weight by default.

@jnothman
Copy link
Member

@jnothman jnothman commented Nov 28, 2020

Not sure if we ever made a decision on it, but the PR as is, has scorers request sample_weight by default.

I don't think we did. I think this is dangerous if estimators don't also request sample weight by default. It is dangerous both because of inconsistency and because if nothing requests sample_weight by default, then an error will be raised but if scorers request by default, then the user passing sample_weight to a cross-validation will silently behave unexpectedly (weighted scoring, unweighted fitting)... but I think estimators requesting sample weight by default is dangerous because they may change over versions and this is not reflected in the code.

We certainly should consider how to give users confidence that their code is doing what they think it should... A debugging tool for instance.

Copy link
Contributor

@lorentzenchr lorentzenchr left a comment

This looks already pretty good. I hope I can help moving this forward.

After a first partial pass, I've the following questions:

  1. This PR is marked as breaking change. I've not detected a backward incompatibility. Are there any?
  2. Is the naming settled? In particular props (cross_validate), request_props (make_scorer) and request_sample_weight (estimators)?
  3. Are we blocking future functionality by introducing **fit_params to fit methods?
  4. Could this be used to pass (meta) data around that is not sample aligned? I suppose so.
  5. What about docs and autocomplete?
return self


class BaseEstimator(_MetadataRequest):

This comment has been minimized.

@lorentzenchr

lorentzenchr Dec 5, 2020
Contributor

As BaseEstimator inherits from _MetadataRequest, we should (later) mention in the docs what's required to roll out your own estimator without inheriting from BaseEstimator, see also discussions around #18797 (comment).

def _empty_metadata_request():
return Bunch(fit={},
predict={},
transform={},
score={},
split={},
inverse_transform={})
Comment on lines +1188 to +1194

This comment has been minimized.

@lorentzenchr

lorentzenchr Dec 5, 2020
Contributor

This defines all the methods which can request meta-data. That is worth a comment, I think.

meta-estimators such as ``Pipeline`` and ``GridSearchCV``. In order to pass a
metadata to a method such as ``fit`` or ``score``, the object accepting the
Comment on lines +9 to +10

This comment has been minimized.

@lorentzenchr

lorentzenchr Dec 5, 2020
Contributor
Suggested change
meta-estimators such as ``Pipeline`` and ``GridSearchCV``. In order to pass a
metadata to a method such as ``fit`` or ``score``, the object accepting the
meta-estimators such as ``Pipeline`` and ``GridSearchCV``. In order to pass
metadata to a method such as ``fit`` or ``score``, the object accepting the

>>> from sklearn.model_selection import GroupKFold
>>> from sklearn.feature_selection import SelectKBest
>>> from sklearn.pipeline import make_pipeline
>>> N, M = 100, 4

This comment has been minimized.

@lorentzenchr

lorentzenchr Dec 5, 2020
Contributor
Suggested change
>>> N, M = 100, 4
>>> n_sampels, n_features = 100, 4

Usage Examples
**************
Here we present a few examples to show different common usecases. The examples
in this section require the following imports and data:

This comment has been minimized.

@lorentzenchr

lorentzenchr Dec 5, 2020
Contributor
Suggested change
in this section require the following imports and data:
in this section require the following imports and data::

Despite make_scorer and LogisticRegressionCV both expecting a key
sample_weight, we can use aliases to pass different weights to different
Comment on lines +105 to +106

This comment has been minimized.

@lorentzenchr

lorentzenchr Dec 5, 2020
Contributor
Suggested change
Despite make_scorer and LogisticRegressionCV both expecting a key
sample_weight, we can use aliases to pass different weights to different
Despite ``make_scorer`` and ``LogisticRegressionCV`` both expecting a key
``"sample_weight"``, we can use aliases to pass different weights to different


request_props : list of strings, or dict of {str: str}, default=None
A list of required properties, or a mapping of the form
``{provided_metadata: required_metadata}``, or None.

This comment has been minimized.

@lorentzenchr

lorentzenchr Dec 5, 2020
Contributor
Suggested change
``{provided_metadata: required_metadata}``, or None.
{"provided_metadata": "required_metadata"}, or None.

[tool.black]
line-length = 79
Comment on lines +32 to +33

This comment has been minimized.

@lorentzenchr

lorentzenchr Dec 5, 2020
Contributor

Not related to this PR. Could you please remove? Fortunately, we will have black with another PR.

@@ -820,13 +822,14 @@ def grad(x, *args): return _logistic_loss_and_grad(x, *args)[1]


# helper function for LogisticCV
def _log_reg_scoring_path(X, y, train, test, pos_class=None, Cs=10,
def _log_reg_scoring_path(X, y, train, test, *, pos_class=None, Cs=10,

This comment has been minimized.

@lorentzenchr

lorentzenchr Dec 5, 2020
Contributor

Not related to this PR, but I like it 👍

@@ -1844,7 +1852,21 @@ def fit(self, X, y, sample_weight=None):

# init cross-validation generator
cv = check_cv(self.cv, y, classifier=True)
folds = list(cv.split(X, y))
scorer = get_scorer(self.scoring)
_params = build_method_metadata_params(

This comment has been minimized.

@lorentzenchr

lorentzenchr Dec 5, 2020
Contributor

Why the leading underscore? Same for _cv_params and _score_params.

@lorentzenchr
Copy link
Contributor

@lorentzenchr lorentzenchr commented Dec 5, 2020

I'd propose to start a vote on SLEP006 before finishing this PR. For example, if 2 reviewers say, this is mature enough for a vote. Reasoning: From a project management point of view, it doesn't make sense to provide a full implementation before the official go-to.

@adrinjalali
Copy link
Member Author

@adrinjalali adrinjalali commented Dec 17, 2020

We had a call with @jnothman , @lorentzenchr , @agramfort and I and here's the summary of the discussion

  • one alternative is to have estimators recieve everything but they ignore them by default

  • requiring explicit request and validation against them gives good error reporing

  • the API should be able to handle typos in given props

  • if an estimator requests my_weight instead of sample_weight, is it expected at the meta-estimator level or the individual estimators

    • meta estimators either pass everything or only the ones that the estimator needs
  • does the pipeline/estimator fail if an estimator request sth but doesn't get it

  • At the moment, there are estimators, splitters and scorers:

    • estimators have to request explicitely, e.g. sample_weight
    • groups are passed to splitter, but a bit inconsequently
    • scorers are difficult as you can pass them as a string and then there is (almost) no way (currently) to make them request sample_weight.

Main Open Questions

1. Routing:

does pipeline pass everything around or only requested ones

  • only pass what's requested, only with explicit name
  • The "pass everything" proposal makes documenting accepted props a bit harder.

2. Validation:

do estimators validate given props against requested ones

  • estimators should do what they're doing now

do metaestimators do validation to check if given props are requested or not

  • meta-estimators do props == given_props

3. Parameters:

do estimators accept only sample_weight, or also a different name that the user specifies like my_weight

  • Alex prefers explicit

4. list or params or dict

we've been passing a list of params (**fit_params)

5. default requested props

  • estimators by default request nothing

  • spliters request groups by default

    • this could be avoided if we had KFold splitter which would optionally accept groups. We should only have a default prop request where it is required by the object and does not switch behaviour.
  • scorers should explicitly request sample_weight

    • estimator passes sample_weight to their scorer by default
    • we could introduce weighted string names for the most used scorers to be passed to meta-estimators such as GridSearchCV
    • meta-estimators' score method should do the same routing as in other places done in meta-estimators
  • AG: also need to explicitly declare not to accept a prop that is available

Notes

make sure things that don't accept a parameter also don't have the method allowing users to request that prop (e.g. ElasticNet)

what to do with meta-estimators which are also consumers

GridSearchCV(LogisticRegression()).fit(X, y, sample_weight):

  • if neither fit nore score of LogisticRegression request sample_weight, then it fails
  • but GridSearchCV(LogisticRegression(), scorer='sample_weighted_accuracy').fit(X, y, sample_weight) would do a weighted scoring and unweighted fit
    • option is if the user passes sample_weight but LogisticRegression doesn't require it, but not explicitly, just by default, is to raise. As in, by default we don't know what to do and we raise.

Can it be experimental?

  • I really don't think so (Adrin)

The current proposal makes the existing code break and not silently change.

We need to also add code snipets to the SLEP and explain what's expected there and expand on the user experience of the API.

Takeaway Message

"If requested, you shall pass."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked issues

Successfully merging this pull request may close these issues.

6 participants
You can’t perform that action at this time.