TST: enforce proper sklearn __init__ for classifiers - #112
Conversation
* Related to lanlgh-111, but only aims to address a subset of it, and only for classifiers for now. If others want to help deal with regressors and other parts of the issue that is welcome. * The regression test added here should now cause a failure when the violation of `sklearn` `__init__` conventions from lanl#70 (comment) is introduced into the source code. * Although I did not use AI in the preparatio of this branch, the original review comment above is from the greptile AI reviewer.
Greptile SummaryThis PR adds a regression test (
Confidence Score: 4/5Safe to merge — the change is additive (a new test only) and does not touch production code. The new test correctly catches the sklearn init convention violation it was designed for. The only concern is that the shared expected dict across both parameterized estimators contains class-specific keys (direct_links for GFDLClassifier, voting for EnsembleGFDLClassifier) that are silently skipped for the estimator that doesn't own them, which could mislead future maintainers into thinking all eight keys are validated for both classifiers. src/gfdl/tests/test_model.py — specifically the shared expected dict and loop structure in test_preserve_class_inputs. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["test_preserve_class_inputs(estimator)"] --> B["clf = estimator(seed=0)"]
B --> C["actual = clf.get_params()"]
C --> D["Loop: for k, v in actual.items()"]
D --> E{k in expected?}
E -- Yes --> F["assert v == expected[k]"]
F --> G["assert isinstance(v, type(expected[k]))"]
G --> D
E -- No --> D
D -- done --> H[Test passes]
subgraph Estimators
I[GFDLClassifier]
J[EnsembleGFDLClassifier]
end
Estimators --> A
subgraph expected_dict ["expected dict (union of both)"]
K["activation, hidden_layer_sizes, reg_alpha, rtol, seed, weight_scheme"]
L["direct_links (GFDLClassifier only)"]
M["voting (EnsembleGFDLClassifier only)"]
end
Reviews (1): Last reviewed commit: "TST: enforce proper sklearn `__init__`" | Re-trigger Greptile |
| for k, v in actual.items(): | ||
| if k in expected: | ||
| assert v == expected[k] | ||
| assert isinstance(v, type(expected[k])) |
There was a problem hiding this comment.
Shared
expected dict silently skips class-specific params
The loop iterates over actual.items() and only validates a key when it appears in both actual and expected. Because the same dict is used for both estimators, direct_links (present in GFDLClassifier but not in EnsembleGFDLClassifier) and voting (present in EnsembleGFDLClassifier but not in GFDLClassifier) each sit as a "dead" entry in expected for the other estimator — they will never be reached in that estimator's test run. This means a reader scanning expected cannot tell which keys are actually validated for which estimator without checking both __init__ signatures. Consider using per-estimator expected dicts, or iterating over expected.items() and guarding with if k in actual, so that omissions are explicit rather than silent.
|
@eiviani-lanl @nray this PR has been open for 3 weeks and only has a 20 line diff -- that should be enough time for a detailed code review. Try to help keep moving things forward with helpful feedback. |
|
Looks like This passes: @pytest.mark.parametrize("estimator", [
GFDLClassifier(),
EnsembleGFDLClassifier()
])
def test_preserve(estimator):
check_parameters_default_constructible(estimator.__class__.__name__, estimator)and I checked failure by changing def __init__(
self,
hidden_layer_sizes: np.typing.ArrayLike = (100,),
activation: str = "identity",
weight_scheme: str = "uniform",
direct_links: bool = True,
seed: int = None,
reg_alpha: float = None,
rtol: float | None = None,
):
self.hidden_layer_sizes = hidden_layer_sizes
self.activation = activation
self.direct_links = direct_links
self.seed = seed
self.weight_scheme = weight_scheme.lower() # forcing a failure
self.reg_alpha = reg_alpha
self.rtol = rtolAlthough I'm fairly certain this should be caught in |
|
Ah nevermind, I understand now. This line in if isinstance(param_value, np.ndarray):
assert_array_equal(param_value, init_param.default) |
|
@eiviani-lanl @nray what's the status here? I still don't see reviewer comments that provide a clear analysis of the situation and a path forward for us, and this 20 line PR has been open for more than a month now. Some things that reviewers may want to check:
|
|
Overall, I suggest that this test would be redundant if the estimators were incorporated into
I focus on modifying the Throughout I will check tests with If you comment out If you hard-code If you define another attribute If you try to perform some operation on the attribute in then you get "RuntimeError: Cannot clone object DecisionTreeRegressor(max_depth=3, random_state=1), as the constructor either does not set or modifies parameter random_state". In these toy examples, I have demonstrated existing tests on
|
Which specific public Is there a public |
|
I think the function The following code will give a list of 55 dictionaries that define a test performed (e.g., All checks pass in the current version for Classifier and Regressor. If I modify the class |
@sdtemple the project currently uses If we look at the docs for the function you mention: https://scikit-learn.org/stable/modules/generated/sklearn.utils.estimator_checks.check_estimator.html it notes:
so it seems to me like we're already following your suggestion. What's really confusing to me is that you keep talking about the general case of bad input, which we already verify/test against, vs. a very specific case of bad input which is described in the links above. As far as I can tell, you're just providing us with general guidance, rather than investigating the very specific scenario that is of concern here. Here is a diff that adds your suggested testing to one of our current API tests (I don't think this is useful, I think it is just duplicating the testing, but let's try it anyway): --- a/src/gfdl/tests/test_model.py
+++ b/src/gfdl/tests/test_model.py
@@ -6,7 +6,7 @@ from sklearn.datasets import load_breast_cancer, load_digits, make_classificatio
from sklearn.metrics import accuracy_score, roc_auc_score
from sklearn.model_selection import StratifiedKFold, train_test_split
from sklearn.preprocessing import OneHotEncoder, StandardScaler
-from sklearn.utils.estimator_checks import parametrize_with_checks
+from sklearn.utils.estimator_checks import parametrize_with_checks, check_estimator
from ucimlrepo import fetch_ucirepo
from gfdl.activations import ACTIVATIONS
@@ -438,6 +438,7 @@ def test_classification_against_grafo(hidden_layer_sizes, n_classes, activation,
@parametrize_with_checks([GFDLClassifier(), EnsembleGFDLClassifier()])
def test_sklearn_api_conformance(estimator, check):
check(estimator)
+ check_estimator(estimator)That test currently passes on latest
Now, let's introduce the specific API problem this PR is focused on, which is clearly linked to above (but which, confusingly, you've not mentioned at all yet... why?): --- a/src/gfdl/model.py
+++ b/src/gfdl/model.py
@@ -35,7 +35,7 @@ class GFDL(BaseEstimator):
reg_alpha: float = None,
rtol: float | None = None,
):
- self.hidden_layer_sizes = hidden_layer_sizes
+ self.hidden_layer_sizes = np.asarray(hidden_layer_sizes)
self.activation = activation
self.direct_links = direct_linksThe testsuite still passes with your suggested additional modification. On the other hand, the new test added in this PR does properly fail with that problem diff. So I think there are some important questions to answer here:
|
In particular, the first comment in this PR #112 (comment) links to it. Also, several of the claims about input validation redundancy made in the review comments above could be pretty quickly disproven by simply running our testsuite against the "general" failure cases vs. the special failure case that is the focus here--make sure you're actually running the testsuite. |
|
Sorry for my confusion earlier on if the PR concerned general versus specific behavior of I transformed array-like inputs in Running the tests in For In if self.weights is not None and len(self.weights) != len(self.estimators):
raise ValueError(
"Number of `estimators` and weights must be equal; got"
f" {len(self.weights)} weights, {len(self.estimators)} estimators"
)The tests that fail here have In def _fit(self, X, y, sample_weight=None, incremental=False):
# Make sure self.hidden_layer_sizes is a list
hidden_layer_sizes = self.hidden_layer_sizes
if not hasattr(hidden_layer_sizes, "__iter__"):
hidden_layer_sizes = [hidden_layer_sizes]
hidden_layer_sizes = list(hidden_layer_sizes)The tests that fail here have |
Agreed, it would be great if
Probably--you'd likely want to word/frame it carefully, after double checking there's no issue already open about this (they have a lot of issues already open, so opening duplicates is an annoyance).
Right, I think your analysis is identical to Emma's a few months back/above at: #112 (comment) From your description, it sounds like Then, as we discussed, we may have to make a decision here on how long that process will take to play out, and decide if we'll just move forward with this for now and then open an issue on our end to remind us to update our testing if they ever make such a utility public. |
|
Okay, I made an issue to start dialogue with the scikit-learn team concerning this attribute type test. |
|
The scikit-learn team responded to my issue. To their first question, I agree with the documentation on instantiation rules and the good and bad examples are clear that you should not transform the inputs in any way in
To their second question, they refer me to a long DOC issue where people discuss confusion surrounding developing sklearn-compatible estimators. @eiviani-lanl @tylerjereddy @nray do you all know the story behind why the code made a type transformation in |
Sounds about right, so you should be able to suggest a clear path forward here and in gh-114 now that you have upstream confirmation that this is not a desirable thing to do.
The background is already explained above, with Navamita making a rough draft PR in gh-70 where an From Tim's response, it seems clear that they agree the mutations in |
|
I approve of this merge request that checks equality and type for values passed to All tests pass (including this one) or are skipped as is. The following are edited examples that would trigger this test in + self.hidden_layer_sizes = np.asarray(hidden_layer_sizes)
- self.hidden_layer_sizes = hidden_layer_sizes
+ self.direct_links = int(direct_links)
- self.direct_links = direct_links
+ self.seed = float(seed)
- self.seed = seedBased on the sklearn response, I don't plan to argue for an addition to their testing suite. As noted previously, there are already, albeit less intuitive and indirect, tests that can detect type transformations in I concur with Tim that this |
|
Ok, I believe you have permission to merge if you're happy then. |
* Related to gh-111, but only aims to address a subset of it. This is the regression equivalent to the classifier-based testing added in gh-112. * Similar to that other PR, the regression test added here also causes a failure when introducing the API violation from #70 (comment). * I did not use AI in the prepartion of this PR, though note that the original review comment that triggered all of this work was from the greptile AI reviewer on GitHub in gh-70.
Related to TST: enforce init/param mutability requirements for sklearn convention #111, but only aims to address a subset of it, and only for classifiers for now. If others want to help deal with regressors and other parts of the issue that is welcome.
The regression test added here should now cause a failure when the violation of
sklearn__init__conventions from WIP, ENH: Add support for gamma scaling and iterative solvers. #70 (comment) is introduced into the source code.Although I did not use AI in the preparation of this branch, the original review comment above is from the greptile AI reviewer.