Check X and y index match when encoding, closes #376 - #377
Check X and y index match when encoding, closes #376#377noahjgreen295 wants to merge 55 commits into
Conversation
solegalli
left a comment
There was a problem hiding this comment.
Thank you so much for this changes. The code looks good.
The OrdinalEncoder also takes y if using the "ordered" strategy. Could you check if we need this function there as well?
There are other transformers in feature-engine that also require y, like the decision tree discretisers and decision tree encoder. But I guess, since they do not concatenate, they would not need this fix? Did you check?
I have a minor suggestion to re-structure the tests as well. Could you have a look?
Thank you!
| @@ -0,0 +1,54 @@ | |||
| import numpy as np | |||
| import pandas as pd | |||
There was a problem hiding this comment.
Can we move the code in this file to this other file were we have the common tests done to more than 1 encoder?
There was a problem hiding this comment.
definitely - I had not noticed that file earlier and it is certainly where it belongs. Fixed in latest commit.
|
|
||
| # Will serve as a no-op whose chief purpose is to turn the | ||
| # X into an np.ndarray | ||
| si = SimpleImputer(strategy="constant", fill_value="a") |
There was a problem hiding this comment.
Can we not just convert the dataframe into an array without calling the simple imputer? it should be faster. Because here we are using the simple imputer just to reset the index and obtain an array from a dataframe. So we could in theory do this with numpy. And then we have less dependencies.
In fact, we could do the following instead:
- create a dataframe X with some index and a y is an array
- create an array X and y is a series.
And we test both, avoiding the simple imputer and the assert types below. Am I correct?
There was a problem hiding this comment.
Definitely agreed about getting rid of SimpleImputer and shortening code; done in latest commit.
However, in terms of this case you mentioned:
- create a dataframe X with some index and a y is an array
I wasn't sure how that situation might come about. I see @bmreiniger discussing similar below so will ask there.
There was a problem hiding this comment.
I am thinking, if a user created a target like this y = np.where(df['my_var']=='yes', 1, 0), that would return an array.
The truth is, I am not sure how often this happens. But at the moment, most of our transformers do not enforce y to be a series. So users could, in practice, pass a numpy array. So it might be worth to expand the check, so that transformers work for them as well.
But then again, I am not so certain about how often this could happen. What is your experience @bmreiniger @noahjgreen295 ?
There was a problem hiding this comment.
Alright, I did some research: most of our transformers that take y, would use a Scikit-learn estimator at the back, so for those, the Check_X_y from sklearn would take care of X and y and we have nothing to worry about.
The only transformers that might have a problem are the encoders, woe, meantargetencoder, probabilityratio, like @noahjgreen295 sayd and I think the ordinal(encoding_method="ordered") should have it too (more below).
Those transformers, if they receive an array for y, they would transform it to a series, and then it would be reindexed from 0. Likely, the index of the converted series will not match the train df index. But if this is the case, the logic in the check_x_y_mismatch as it is now, should handle it. But, note that we would be giving the df an index that is reindexed from 0.
So I would say, let's add a test for when X is dataframe, and y is an array, just to corroborate that what I am saying is true. But I don't think we need to change the logic of the check_x_y_mismatch.
Also, the ordinalencoder(encoding_method="ordered") uses exactly the same logic as all other encoders. So I am not sure why that transformer would not fail the test if we do not add the check_x_y mistmatch. If you added a test to see that it passes, that would be great.
|
It won't come up as often, but I wonder these two other cases should be considered:
The former is reasonably straightforward, but the second requires a decision: should it error, or warn and reindex, or silently reindex, or operate as though the rows in |
|
y is supposed to be the observation-wise data for X, so they should also match in lenght. We have a separate issue to add a check for x and y lenght mismatch #365 in case you guys have some time in your hands :) But to @bmreiniger 's suggestion, I think we should act without a warning, lol. Probably silently re-index. |
|
Hi - will definitely take care of these changes. |
|
No problem at all. Enjoy your holidays! |
Thanks! Will try to do a little bit now as well. |
|
ww
Re this case
Re this case
|
Yes, tested this one - did not have the issue and did not require the fix.
I did test DecisionTreeEncoder and it did not have the issue. Once again am adding it to the test suite for coverage. |
|
Thinking it further, the second example, where X and y have a different index yet they are both pandas objects: if we handled it silently, we might be doing more bad than good, because mis-aligned indeces might be a symptom of something going wrong somewhere up in the pipeline. I mean, why would they not match if they are 2 dataframes? I could imagine that I re-indexed my train set, but forgot to reindex my y. That has indeed happened to me. But then, that should be on the user to pick up. Otherwise, it is too risky. So maybe we let that one go for the moment? What's your thoughts? |
|
Hi guys @noahjgreen295 @bmreiniger Just checking in to see if, by any chance, you would have time to finish this PR? Not long to go I believe :) thank you! |
Sorry I've been away! Dealing with some job-related disruption that came after returning from vacation. Things are settling down now - I should be able to take care of by early next week. Sorry for this delay! |
|
Hi - I'm back on the case :-) Sorry again for delay. |
|
I think that's a big enough difference to warrant a separate issue and/or pull request. The indexes we can match up with The |
Got it - will limit to current issue. thx |
Sorry, forgot to respond here - |
|
OK all relevant changes posted. Remaining tasks:
Should be able to get these done tomorrow or Tuesday. |
|
OrdinalEncoder indeed does have a problem. It's just that the incorrect joining causes the |
Agreed - spotted same last night. Will modify the unit tests to look for expected return values rather than just non-NaN. Should definitely be able to get to that today. |
|
OK latest is pushed - I think we are there! |
solegalli
left a comment
There was a problem hiding this comment.
Thank you for the code changes and the thorough testing.
I tried to flesh out the possible situations that we may encounter when working with feature-engine encoders, to better understand when we should raise an error and when we should not.
I wonder if what I wrote in #376 makes sense, and if yes, if we could incorporate all the scenarios in the X_y-check?
thank you!
| elif isinstance(X, (pd.DataFrame, pd.Series)) and isinstance(y, np.ndarray): | ||
| y = pd.Series(y) | ||
| y.index = X.index | ||
|
|
There was a problem hiding this comment.
I think we could simplify this test a bit.
X can only be a pandas dataframe at this stage, because if it was an array, is_dataframe converted it to a df, and if it was something else that is not permitted, like a pd.Series, is_dataframe should have raised an error (if it does not, we need to fix is_dataframe).
So when we call this function within the classes, the only chance is X is a dataframe, and y can be, in theory, an array or a series.
I think sklearn also allows arrays as targets, but I don't think we can use that with the encoders. Our encoders are tailored to binary classification mostly. So we probably need that check as well :_(
There was a problem hiding this comment.
I think, given the scenarios that I posed in #376 the best solution would be to replace _is_dataframe in the encoders by a new function that checks simultaneously X and y, because depending on the input combination we should raise errors or not.
thoughts?
There was a problem hiding this comment.
I was thinking something like that earlier but was afraid to make changes to calls to _is_dataframe() :-) Now I know that's OK so will have a look tonight, from what I can tell this makes sense.
There was a problem hiding this comment.
X can only be a pandas dataframe at this stage, because if it was an array, is_dataframe converted it to a df
In commits from earlier, I deliberately put the call to _check_X_y_pd_np_mismatch() before the call to self._check_fit_input_and_variables() (and thus _is_dataframe()) so that X has the chance to arrive at _check_X_y_pd_np_mismatch() as an array. (For an example, see here ) This allows me to detect that particular error case.
and if it was something else that is not permitted, like a pd.Series, is_dataframe should have raised an error (if it does not, we need to fix is_dataframe)
This is correct. I'll remove the logic that handles cases of X being a Series since that is not possible.
I do feel good about merging the functionality of _check_X_y_pd_np_mismatch() into self._check_fit_input_and_variables(). However, I think it might mean some restructuring of the unit tests, so please confirm.
I have additional followup on the #376 page.
There was a problem hiding this comment.
I would not change is_dataframe because it is used all over our codebase.
I would do the following:
if the encoder needs X and y, instead of using _is_dataframe, use directly your check and return a pandas dataframe and a pd series.
If encoder does not need y: then use is_dataframe as usual.
If encoder has the option to do both (OrdinalEncoder):
if self.encoding_method == "ordered":
X, y = check_X_y_pd_np_mismatch(X, y)
else:
X = is_dataframe(X)
This means, taking the _is_dataframe out of _check_input_and_variables() hidden method in the base_encoder and amending the code slightly in all the classes.
|
All changes done except whether to merge logic of |
solegalli
left a comment
There was a problem hiding this comment.
Thank you for the quick turnaround @noahjgreen295 !!! Much appreciated :)
I think we are almost there. As it stands now, due to code legacy, we are running the same functionality (is_dataframe(X)) twice in some transformers.
But if we change the code slightly here and there, we can make this more elegant and performant.
I would suggest the following:
- check_x_y should return a df and a series and handle all possible ok cases, problematic cases and error cases (most of it is already there)
- in base encoder: remove is_dataframe from
_check_fit_input_and_variableswhich would now returnself - for the encoders that do not require y, add is_dataframe when needed
- for encoders that do require y, check_x_y handles everything, including transforming y into a series if necessary
- OrdinalEncoder needs special attention because it should be able to work with and without y
Would you be up for these changes @noahjgreen295 ?
Thank you!
| 1. X is an ndarray and y is a Series - converts X to DataFrame with y's index | ||
| 2. X is a DataFrame and y is an ndarray - converts y to Series with X's index | ||
| 3. X is a DataFrame and y is a Series, but their indexes don't match | ||
| - raises an error |
There was a problem hiding this comment.
This check is looking really good.
I would refactor it slightly so that it returns X and y as a Dataframe and Series, so we do not have additional work to do within the classes. So:
def _check_X_y_pd_np_mismatch(
X: Union[pd.DataFrame, np.ndarray],
y: Union[pd.Series, np.ndarray],
) -> Tuple[pd.DataFrame, pd.Series]:
The only missing functionality would be for when both X and y are arrays, that instead of being returned unchanged, we should return a df and a series.
|
|
||
| Returns | ||
| ------- | ||
| X: changed as per description above |
| Returns | ||
| ------- | ||
| X: changed as per description above | ||
| y: changed as per description above |
| elif isinstance(X, (pd.DataFrame, pd.Series)) and isinstance(y, np.ndarray): | ||
| y = pd.Series(y) | ||
| y.index = X.index | ||
|
|
There was a problem hiding this comment.
I would not change is_dataframe because it is used all over our codebase.
I would do the following:
if the encoder needs X and y, instead of using _is_dataframe, use directly your check and return a pandas dataframe and a pd series.
If encoder does not need y: then use is_dataframe as usual.
If encoder has the option to do both (OrdinalEncoder):
if self.encoding_method == "ordered":
X, y = check_X_y_pd_np_mismatch(X, y)
else:
X = is_dataframe(X)
This means, taking the _is_dataframe out of _check_input_and_variables() hidden method in the base_encoder and amending the code slightly in all the classes.
| ) | ||
|
|
||
|
|
||
| def _check_X_y_pd_np_mismatch( |
There was a problem hiding this comment.
I would call this method just _check_X_y()
| check_classification_targets(y) | ||
|
|
||
| # check input dataframe | ||
| X, y = _check_X_y_pd_np_mismatch(X, y) |
There was a problem hiding this comment.
Do we need this for this encoder? because X and y are being passed to cross_validate, and that should call for sklearn machinery to take care of X and y.
There was a problem hiding this comment.
The test_detect_index_mismatch_from_x_pandas_y_pandas() unit test fails without it. I think it has to do with DecisionTreeEncoder using OrdinalEncoder but not sure. At any rate this call seems to be needed here to pass unit tests.
| """ | ||
|
|
||
| X, y = _check_X_y_pd_np_mismatch(X, y) | ||
| X = self._check_fit_input_and_variables(X) |
There was a problem hiding this comment.
I would remove _is_dataframe from _check_fit_input_and_variables(X) which would now return self
this way we don't carry on the same process twice (check that X is a dataframe)
| X, y = _check_X_y_pd_np_mismatch(X, y) | ||
| X = self._check_fit_input_and_variables(X) | ||
|
|
||
| if not isinstance(y, pd.Series): |
There was a problem hiding this comment.
I would remove these lines of code, and I would make this part of check_X_y, to reduce boilerplate
| Otherwise, y needs to be passed when fitting the transformer. | ||
| """ | ||
|
|
||
| X, y = _check_X_y_pd_np_mismatch(X, y) |
There was a problem hiding this comment.
I would restruture the logic here a bit:
if encoding_method is ordered, use check_x_y, otherwise use _is_dataframe()
| def test_all_transformers(Estimator): | ||
| return check_estimator(Estimator) | ||
|
|
||
|
|
There was a problem hiding this comment.
I think rebasing main would help solve the incompatibility with this file
|
All of above makes sense to me - I can definitely do it! Should have either today. Thanks for looking through the codebase and pointing out the locations, that is a big help - much appreciated. |
Update - some deliverables for work coming up, so it will take a big longer but should have by end of week. |
|
Update - nearly done. Should have today or tomorrow. I did everything but older unit tests are breaking, I think because some of the new code is catching certain errors earlier and thus breaking the expectations of unit tests of downstream code. Should have sorted out soon. |
|
All set! Let me know what you think.
Also:
|
…ix is done. Made it a separate .py file because it affects multiple encoders; parameterized it for each encoder with the known issue
…ough this function addresses issues that so far only pertain to some BaseEncoder subclasses, am putting it here as it may be useful for other situations
|
Updating notes from above: All set! Let me know what you think.
|
solegalli
left a comment
There was a problem hiding this comment.
Thank you so much. I like how it reads with the numpy functions you created.
The tests are failing in my local branch, there is some int vs float difference between the input and expected df both for dataframe checks and numpy.
And at the back of it, would it be possible to break the tests down into smaller tests that check a specific bit of the functionality? This way it is easier to debug whenever a test starts failing.
I added a comment below.
Thank you!
| ), | ||
| ], | ||
| ) | ||
| def test_check_pd_X_y( |
There was a problem hiding this comment.
would it be possible to break this test into smaller tests that test one particular bit of the functionality? maybe the errors in one test? when both are numpy in a different test and so on?
The reason is, that tests over multiple functionality are difficult to debug. In fact, I changed slightly the logic of checK_pd_X_y and now I am finding hard to find out where the error is coming from.
There was a problem hiding this comment.
Sure - that is always the balance between the fixtures and repeating code. At very least let me break out error conditions tests into one test, as you suggested. Will do right now.
Not sure why tests are failing on your branch, I'll do another git pull upstream to make sure I'm based off the same code. Will have all in a few minutes.
…ck_pd_X_y_both_same_type(), test_check_pd_X_y_np_to_pd(), and test_check_pd_X_y_errors()
|
OK, pushed changes with test broken up into 3 new functions for easier distinction of errors. My versions are: |
| self.n_features_in_ = X.shape[1] | ||
|
|
||
| return X | ||
| return self |
There was a problem hiding this comment.
The typehint and docstring should be changed to match this updated return behavior. I might suggest not returning anything, but I did see @solegalli suggested specifically self.
There was a problem hiding this comment.
OK fixed in latest commit. Convention in rest of code for return self seems to be blank typehint and no mention in docstring, so I went with that.
There was a problem hiding this comment.
@bmreiniger your comment was very enlightening. Following sklearn convention, the fit method needs to return self. And I now found out that it might be to allow method cascading, so class.fit().transform() which would not be possible without returning self.
So indeed, there is no need to return self in this method, because we won't cascade it. In fact, it is an internal method.
Here is the reference I found, mostly for my information lol:
https://stackoverflow.com/questions/43380042/purpose-of-return-self-python
Thank you!
There was a problem hiding this comment.
@solegalli OK should I change to not return self?
| ) | ||
|
|
||
|
|
||
| def _check_pd_X_y( |
There was a problem hiding this comment.
Hi guys, I spent a good few hours going over this function, and I still can't decide what is best.
The thing is, if we are going to replace is_dataframe with this function, then the function should be able to return a copy of the df (like is_dataframe) and potentially a copy of y (not sure is necessary), to avoid inadvertently modifying the user's data. So before merging, at least, we need to ensure we return a copy of X.
When playing around with this function, I found out some errors in the tests raised by check_estimator. One of the tests would be if y=None, and I've noticed that we do not handle this situation. At the moment, we are assuming that the user would enter a numpy array or a series, but in theory, they could also enter a list, a tuple or None, and all of that would work just fine with this version of check_pd_x_y and still fail in the concatenation, unless we ensure we return a pd.series from this function.
But, pd.Series(None) would still return a pd.series. And if we do pd.Series(None, index=X.index) it will return a pd series of the length of the dataframe full of nan, when the user enters None.
If I convert a numpy array to a df, and force the index of the dataframe, what if the array was shorter than the df? would it fail or would it introduce Nan? I did not check this, so thinking out loud.
I then went ahead and checked the check_x_y function from sklearn for some inspiration, and I see that they handle the y=None at the very top of the check. So we probably should do that as well.
There was a problem hiding this comment.
If I convert a numpy array to a df, and force the index of the dataframe, what if the array was shorter than the df? would it fail or would it introduce Nan? I did not check this, so thinking out loud.
Pretty sure I check for incompatible dimensions and unit test it, but I'll double check.
I'll go ahead and make the following changes:
- Return copies of X and y when they are incoming as pandas objects
- Raise an exception if y (or X) is None
- Accommodate other array-like inputs for y
Sound good?
… empty; unit tests to confirm
|
OK done. Let me know if these changes work. |
|
Hi - am working on those unit tests failing, should have tomorrow. I think it's all about the new code catching errors sooner than the sklearn |
FYI #410 I would appreciate your thoughts on that PR and if you have code change suggestions, could you PR to that branch please? |
|
Sorry, was delayed by some work stuff. Heading over to the new PR now! |
It will close automatically when we merge the other one. |
Fixes for issue 376