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

Add nullable type incompatibility properties to the components that have them #4031

Merged
merged 16 commits into from
Mar 2, 2023

Conversation

tamargrey
Copy link
Contributor

@tamargrey tamargrey commented Feb 24, 2023

closes #3991

Adds accurate _integer_nullable_incompatibilities and _boolean_nullable_incompatibilities properties to the components that are known to have nullable type incompatibilities. Tests that those incompatibilities cause errors that are fixed by the handling. Also tests that components without incompatibilities can use the nullable types.

Note - does not actually start calling _handle_nullable_types inside fit/predict/transform yet, as that would start using it in automl search. So we do not yet expect to see any impact to search performance or runtimes.

@codecov
Copy link

codecov bot commented Feb 24, 2023

Codecov Report

Merging #4031 (1ceef7f) into main (337b44f) will increase coverage by 0.1%.
The diff coverage is 100.0%.

❗ Current head 1ceef7f differs from pull request most recent head 36723c8. Consider uploading reports for the commit 36723c8 to get more accurate results

@@           Coverage Diff           @@
##            main   #4031     +/-   ##
=======================================
+ Coverage   99.7%   99.7%   +0.1%     
=======================================
  Files        349     349             
  Lines      37257   37472    +215     
=======================================
+ Hits       37136   37354    +218     
+ Misses       121     118      -3     
Impacted Files Coverage Δ
...ents/estimators/classifiers/lightgbm_classifier.py 100.0% <100.0%> (ø)
...omponents/estimators/regressors/arima_regressor.py 100.0% <100.0%> (ø)
...tors/regressors/exponential_smoothing_regressor.py 100.0% <100.0%> (ø)
...onents/estimators/regressors/lightgbm_regressor.py 100.0% <100.0%> (ø)
...nents/transformers/imputers/time_series_imputer.py 100.0% <100.0%> (ø)
...es/components/transformers/samplers/oversampler.py 100.0% <100.0%> (ø)
...alml/tests/component_tests/test_arima_regressor.py 100.0% <100.0%> (ø)
evalml/tests/component_tests/test_components.py 99.1% <100.0%> (+0.1%) ⬆️
...nent_tests/test_exponential_smoothing_regressor.py 100.0% <100.0%> (ø)
...alml/tests/component_tests/test_lgbm_classifier.py 100.0% <100.0%> (ø)
... and 5 more

Help us with your feedback. Take ten seconds to tell us how you rate us. Have a feature suggestion? Share it here.

subset_cols=X_schema._filter_cols(
exclude=["IntegerNullable", "BooleanNullable"],
exclude=["IntegerNullable", "BooleanNullable", "AgeNullable"],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll likely remove this logic once we integrate the new nullable type handling into AutoMLSearch, but I wanted to add in AgeNullable here so that we could test with it now.

The one reason we may keep is is that nans are present at this stage, so we'll have to convert types to Double and Categorical, and we may want logic to not keep those types once nans are gone and we can use Integer and Boolean. I'll put more thought into this when I get to #3999

evalml_arima.fit(X_train, y_train)

# Confirm that the handle nullable types method fixes the error for AutoARIMA
X_train_d, y_train_d = evalml_arima._handle_nullable_types(X_train, y_train)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we integrate the new handling into automl search, this call to _handle_nullable_types will be inside fit and predict, so we'll remove this. Keeping the error checking for sk_arima will help us know when sktime adds support for integer nullable on arima.

["BooleanNullable", "IntegerNullable", "AgeNullable"],
)
# --> this is 180 tests - is it overkill?
def test_components_support_nullable_types(
Copy link
Contributor Author

@tamargrey tamargrey Feb 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like we want the tests for making sure our compatible components are actually compatible to be exhaustive. This does add ~180 tests, though, so I just want to make sure there's visibility around that.


with pytest.raises(Exception):
comp.fit(X, y)
comp.predict(X)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we are using the actaul evalml components here, this will have to change once we integrate into automl search to call the underlying component_obj so that we can track when support gets added for that component.

"Unknown label type: 'unknown'",
),
):
oversampler.transform(X, y)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to the test_estimators.py test - we are using the actual evalml component here, so this will have to change once we integrate into automl search

if has_nans:
y = pd.Series([1, 0, pd.NA, 1, 0] * 4)
else:
y = pd.Series([1, 0, 1, 1, 0] * 4)
y = pd.Series([1, 0, 1, 1, 1] * 4)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed so I could test the oversampler better :)

Copy link
Collaborator

@jeremyliweishih jeremyliweishih left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - just one clarifying question

X.ww.set_types(logical_types={"feature": "IntegerNullable"})
X.ww["bool col"] = bool_col
else:
y = nullable_type_target(ltype=nullable_y_ltype, has_nans=False)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why don't we run y = nullable_type_target(ltype=nullable_y_ltype, has_nans=False) for components that require the time index?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I probably could; I'd just need to change nullable_type_target to be the correct length for ts_data and also update it to have the same datetime index values as X. I felt like it was simpler to just create bool_col and set y to be it.

Copy link
Contributor

@eccabay eccabay left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty good! Mostly just test cleanups and clarifying questions, but nothing blocking.

@@ -194,3 +199,21 @@ def transform(self, X, y=None):
y_imputed = ww.init_series(y_imputed)

return X_not_all_null, y_imputed

def _handle_nullable_types(self, X=None, y=None):
"""Transforms X and y to remove any incompatible nullable types for the time series imputer when the interpolate method is used.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to treat interpolation differently than the other fill methods? And will this remove any necessary handling for the other methods?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interpolation is the only one that has any incompatibilities with nullable types, so there's no need to remove nullable types for the other fill methods.

evalml/tests/component_tests/test_components.py Outdated Show resolved Hide resolved
evalml/tests/component_tests/test_components.py Outdated Show resolved Hide resolved
evalml/tests/component_tests/test_estimators.py Outdated Show resolved Hide resolved
evalml/tests/component_tests/test_lgbm_classifier.py Outdated Show resolved Hide resolved
evalml/tests/component_tests/test_lgbm_classifier.py Outdated Show resolved Hide resolved
evalml/tests/component_tests/test_lgbm_regressor.py Outdated Show resolved Hide resolved
evalml/tests/component_tests/test_time_series_imputer.py Outdated Show resolved Hide resolved
@@ -558,3 +560,101 @@ def test_imputer_woodwork_custom_overrides_returned_by_components(
transformed.ww.logical_types["categorical with nan"]
== X.ww.logical_types["categorical with nan"]
)


def test_imputer_nullable_handling_numeric_interpolate(nullable_type_test_data):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not convinced that these tests are how we want to be testing the TimeSeriesImputer._handle_nullable_types. It feels like too much is testing sklearn's interpolate, rather than our imputation. I do see why you did it, since just doing the (running the component fails -> calling _handle_nullable_types fixes it) check of other estimators doesn't work here. I think it would be better to combine this test and the one below into one, and focus on testing that the logical splits to only handle nullable types in the interpolation case works as intended. We already have tests ensuring that the super()._handle_nullable_types has the desired behavior.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a fair point, and I recognize that we do not generally want to be testing our dependencies' behaviors. Part of the goal of this, beyond confirming that our handling lets us avoid the problem, is that this check will notify us when support is added. I have a similar check in the arima tests, and am planning to eventually have them for all incompatibilities.

This is just my preference for how to keep track of our dependencies adding support, though. I recognize it means that when a pandas version is released with support interpolating nullable types, for example, we'll get an error that we will need to handle before we can merge any dependency updates.

That will be a bit of a pain, but it allows us to remove this handling when possible without having to manually keep track of when dependencies add support, which I would argue is more of a pain. Happy to open this up to a larger discussion with the rest of the team.

["BooleanNullable", "IntegerNullable", "AgeNullable"],
)
@pytest.mark.xfail(strict=True, raises=ValueError)
def test_time_series_imputer_nullable_type_incompatibility(
Copy link
Contributor Author

@tamargrey tamargrey Mar 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chukarsten @eccabay @jeremyliweishih I'm still adding the rest of the xfail tests but wanted to run this by yall.

  • I have the xfail set to only expect ValueError, but unfortunately, we can't specify the expected message. Would people prefer I get as specific about what the expected message is? My thought here is just that I feel like being able to use this format of xfailing where we write code that errors and say that the code is expected to error feels more true to our goal here than triggering the xfail from the test directly when catch the error ourselves.
  • The strict parameter means that our test suite will fail when tests start passing - otherwise they just show as XPASS which I think would be easy to miss. But if we want to avoid blocking the update checker, we could set this to False and just periodically remember to check. I think that's probably a halfway point between what I'm suggesting and the fully manual checking process.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method of xfail seems like the best to me!

I'm a fan of keeping strict=True. It's really easy to miss an XPASS, and we don't have any way of flagging them at this point. I'm pretty sure we already have a bunch of XPASSing tests that we might want to revisit...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sliiight change so that we can test that our _handle_nullable_types fixes the incompatibility - we will expect tests to pass when we handle incompatibilities and xfail other times

@pytest.mark.parametrize(
    "nullable_ltype",
    ["BooleanNullable", "IntegerNullable", "AgeNullable"],
)
@pytest.mark.parametrize(
    "handle_incompatibility",
    [
        True,
        pytest.param(
            False,
            marks=pytest.mark.xfail(strict=True, raises=ValueError),
        ),
    ],
)
def test_time_series_imputer_nullable_type_incompatibility(
    nullable_type_target,
    handle_incompatibility,
    nullable_ltype,
):
    """Testing that the nullable type incompatibility that caused us to add handling for the time series imputer
    is still present in pandas' interpolate method. If this test is causing the test suite to fail
    because the code below no longer raises the expected ValueError, we should confirm that the nullable
    types now work for our use case and remove the nullable type handling logic from TimeSeriesImputer."""
    nullable_series = nullable_type_target(ltype=nullable_ltype, has_nans=True)
    if handle_incompatibility:
        imputer = TimeSeriesImputer(target_impute_strategy="interpolate")
        imputer.fit(pd.DataFrame(), nullable_series)
        _, nullable_series = imputer._handle_nullable_types(None, nullable_series)
         
    nullable_series.interpolate()

# since the category dtype also has incompatibilities with linear interpolate, which is expected
# --> i think this is essentially what happens now but won't that make floating point values that cant be turned back into bools?
if isinstance(y.ww.logical_type, BooleanNullable):
y = ww.init_series(y, Double)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noticed that interpolate() cannot handle category dtypes either. But this, I think, is by design, as the default interpolation is "linear" which doesnt make sense for non numeric values. This is problematic if we try to turn BooleanNullable into Categorical ltype when nans are present, so I'm intercepting it and converting to Double, which essentially the current behavior.

In the long run, though, I think we don't want to be doing linear interpolation on boolean columns ever - we don't want to end up with 1.5 in a column of all 1s and 0s. I'll open a separate evalml ticket for handling that, though.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already don't allow using interpolation with categorical or boolean types. From the docstring:

        categorical_impute_strategy (string): Impute strategy to use for string, object, boolean, categorical dtypes.
            Valid values include "backwards_fill" and "forwards_fill". Defaults to "forwards_fill".
        numeric_impute_strategy (string): Impute strategy to use for numeric columns. Valid values include
            "backwards_fill", "forwards_fill", and "interpolate". Defaults to "interpolate".

It's enforced in a couple different places, between checking the arguments and how we select splitting between numeric and categorical!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only ran into it with the target impute strategy, which I think doesn't have the same safeguards.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, very true - good point!

Copy link
Collaborator

@jeremyliweishih jeremyliweishih left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - great work!

# since the category dtype also has incompatibilities with linear interpolate, which is expected
# --> i think this is essentially what happens now but won't that make floating point values that cant be turned back into bools?
if isinstance(y.ww.logical_type, BooleanNullable):
y = ww.init_series(y, Double)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense!

@tamargrey tamargrey force-pushed the component-nullable-handling branch 3 times, most recently from c159a46 to 1ceef7f Compare March 2, 2023 14:45
@tamargrey tamargrey force-pushed the component-nullable-handling branch from 1ceef7f to 36723c8 Compare March 2, 2023 16:00
@tamargrey tamargrey enabled auto-merge (squash) March 2, 2023 16:21
@tamargrey tamargrey merged commit 33eb2c6 into main Mar 2, 2023
@tamargrey tamargrey deleted the component-nullable-handling branch March 2, 2023 16:31
@chukarsten chukarsten mentioned this pull request Mar 15, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add component specific nullable type handling logic to necessary components
3 participants