Repository navigation
fix(evaluation): load_model raises RecursionError on models holding an evaluator - #450
Open
nikolas-sapa wants to merge 1 commit into
Conversation
save_model worked on a BanditClassifier but load_model died with a RecursionError, at evaluation.py:254 in ClassificationEvaluator.__getattr__. The recursion is real, and it is not about the size of the object graph. Python calls __getattr__ for any attribute that is not set, and unpickling starts by asking a freshly made, still empty object for __setstate__. __getattr__ answered by calling metrics_header(), which reads self.moa_basic_evaluator, which was not set yet, so the lookup came back to __getattr__ and repeated until the stack ran out. Two frames alternating, so the traceback is thousands of lines of the same two lines. Nothing about BanditClassifier is special. It holds a list of ClassificationEvaluator objects, so it unpickles them, so it hits this. Any evaluator that owns a ClassificationEvaluator does the same, and a bare ClassificationEvaluator does it on its own. Three sites have the shape, so all three are fixed: - ClassificationEvaluator.__getattr__ and ClassificationWindowedEvaluator.__getattr__ read self.moa_basic_evaluator, and both returned None for a name they did not know. They now stop when the evaluator is not in __dict__ and raise AttributeError otherwise. - PrequentialResults.__getattr__ already raised AttributeError, but read self.cumulative on the way, so it recursed in exactly the same way. It now stops when the cumulative evaluator is not there. Raising AttributeError for an unknown metric is also what makes the hasattr(result.windowed, "rmse") check in plot_regression_results work. Returning None meant hasattr was always True, so that guard could never reject classification results; it does now. The recursion limit is untouched. Raising it would only have moved the failure to the next object in the graph. Tests: a BanditClassifier round-trips and then predicts identically to the original on held-out instances, which also checks the evaluators came back; a bare ClassificationEvaluator round-trips on its own, since that is the smallest object that reproduces it; and __getattr__ declines a name on an object built with __new__, with no unpickling involved, so the test does not have to wait for a RecursionError to fail. All three fail on the previous commit. Assisted-by: opencode:space-bunny-free
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
save_modelsucceeds on aBanditClassifier, butload_modelraisesRecursionError:The traceback is ~3000 lines of two frames repeating, reported at
evaluation.py:254,ClassificationEvaluator.__getattr__.Cause
The recursion is real, and it is not about the size of the object graph.
Python calls
__getattr__for every attribute that is not set. Unpickling begins by asking a freshly created, still empty object for__setstate__, so the lookup lands in__getattr__, which answered by callingmetrics_header():self.moa_basic_evaluatoris not set yet at that point, so the attribute lookup came straight back to__getattr__and repeated until the stack ran out. Two frames alternating.Nothing is special about
BanditClassifier. It holds a list ofClassificationEvaluatorobjects, so unpickling it unpickles them, so it hits this. Any object owning aClassificationEvaluatordoes the same, and a bareClassificationEvaluatordoes it on its own:BanditClassifierRecursionErrorClassificationEvaluatorRecursionErrorClassificationWindowedEvaluatorRecursionErrorPrequentialResultsRecursionErrorRegressionEvaluatorHoeffdingTree(control)Fix
Three sites have this shape, so all three are fixed:
ClassificationEvaluator.__getattr__andClassificationWindowedEvaluator.__getattr__readself.moa_basic_evaluator, and both returnedNonefor a name they did not know. They now stop when the evaluator is not in__dict__, and raiseAttributeErrorotherwise.PrequentialResults.__getattr__already raisedAttributeError, but readself.cumulativeon the way, so it recursed in the same way. It now stops when the cumulative evaluator is not there.Why not raise the recursion limit
Raising the limit would only push the failure onto the next object in the graph, and would leave every future unpickling of these classes slow and fragile. The recursion limit is untouched by this change.
Second bug fixed by the same change
Returning
Nonefor an unknown metric meanthasattrwas alwaysTrueon these objects. That made the guard inplot_regression_resultsdead code:This never fired, because
hasattr(result.windowed, "rmse")returnedTrueeven for classification results. Verified before and after:Tests
test_bandit_classifier_save_load_roundtrip— round-trips aBanditClassifier, then checks it predicts identically to the original on 20 held-out instances, and that the evaluator state came back. Identity of predictions is what makes it load-bearing: a graph that loads but loses the models or evaluators fails here.test_classification_evaluator_save_load_roundtrip— the smallest object that reproduces the bug, so the test pins the root cause rather than the object that happens to hold one.test_evaluator_getattr_does_not_recurse_before_init—__getattr__must decline a name on an object built with__new__. No unpickling involved, so the test fails on the recursion itself instead of waiting for aRecursionError.All three fail on the parent commit
65a3262:and pass on this branch:
4 passed.Verification
uv run pytest -q tests --ignore=tests/ocl(torch is not installed in this environment, sotests/oclfails collection):main@65a3262Same 9 failures before and after, all
ModuleNotFoundError: No module named 'torch'. The 3 extra passes are the 3 new tests.uv run invoke fmtis clean.