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

ENH Simplify pytest global random test plugin #27963

Merged
merged 21 commits into from
Jun 28, 2024

Conversation

lesteve
Copy link
Member

@lesteve lesteve commented Dec 14, 2023

Reference Issues/PRs

Fix #27806

@Charlie-XIAO can you double-check this fixes it for you? This fixes it in @fcharras Windows VM and mine too, but one more confirmation would be nice.

What does this implement/fix? Explain your changes.

I think we were in a weird edge-case of pytest by combining registering a plugin in the sklearn tree calling it via setup.cfg and sometimes registering inside conftest.py some reason. It seems like by moving all the code to conftest.py we avoid the issue.

pytest_generate_tests seems a slightly simpler way to parameterize the global_random_seeds tests based on the variable SKLEARN_TESTS_GLOBAL_RANDOM_SEED.

cc @jeremiedbb who was involved in the global random seed plugin originally IIRC #22749. Edit: actually looks like @ogrisel was the one who created the global random seed plugin.

Copy link

github-actions bot commented Dec 14, 2023

✔️ Linting Passed

All linting checks passed. Your pull request is in excellent shape! ☀️

Generated for commit: 1801de0. Link to the linter CI: here

@Charlie-XIAO
Copy link
Contributor

@lesteve Yes I confirm this PR fixes the issue also on my local machine. Thank you very much!

@lesteve
Copy link
Member Author

lesteve commented Jan 3, 2024

As asked by @jeremiedbb I double-checked that the following command works (he remembered some issues when working on the original PR):

SKLEARN_TESTS_GLOBAL_RANDOM_SEED='1-10' pytest --pyargs sklearn.tests.test_dummy

The only weird behaviours I noticed:

  • pytest --pyargs sklearn.tests.test_dummy doesn't print the header with the global random seed info, but the global random seed is set correctly. My guess is that in this case sklearn/conftest.py is run but too late to add a header
  • as I mentioned in one of the comment pytest --pyargs sklearn.tests.test_dummy -n2 does not seem to run pytest_configure_node so in the 'any' case we set a fixed random_state (the default value). I don't think this matters that much in practice.

Copy link
Member

@ogrisel ogrisel left a comment

Choose a reason for hiding this comment

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

I think fixing the "any" case with xdist is important. See below:

sklearn/conftest.py Outdated Show resolved Hide resolved
# In these edge cases, random_seeds is set to a fixed value
random_seeds = getattr(
config.workerinput, "random_seeds", default_random_seeds
)
Copy link
Member

Choose a reason for hiding this comment

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

It does not seem to pickup the seed randomly selected by the controller:

SKLEARN_TESTS_GLOBAL_RANDOM_SEED="any" pytest -n 8 -v sklearn/cluster/tests/test_k_means.py -k test_sample_weight_init
====================================================================================================== test session starts =======================================================================================================
platform darwin -- Python 3.11.6, pytest-7.4.2, pluggy-1.3.0 -- /Users/ogrisel/miniforge3/envs/dev/bin/python3.11
cachedir: .pytest_cache
To reproduce this test run, set the following environment variable:
    SKLEARN_TESTS_GLOBAL_RANDOM_SEED="89"
See: https://scikit-learn.org/dev/computing/parallelism.html#sklearn-tests-global-random-seed
rootdir: /Users/ogrisel/code/scikit-learn
configfile: setup.cfg
plugins: repeat-0.9.2, anyio-4.0.0, cov-4.1.0, xdist-3.3.1
8 workers [2 items]     
scheduling tests via LoadScheduling

sklearn/cluster/tests/test_k_means.py::test_sample_weight_init[42-random] 
sklearn/cluster/tests/test_k_means.py::test_sample_weight_init[42-k-means++] 
[gw0] [ 50%] PASSED sklearn/cluster/tests/test_k_means.py::test_sample_weight_init[42-random] 
[gw1] [100%] PASSED sklearn/cluster/tests/test_k_means.py::test_sample_weight_init[42-k-means++] 

======================================================================================================= 2 passed in 1.69s ========================================================================================================

It's always running the tests with 42 while it mentions 89 in the test report header message.

Copy link
Member

Choose a reason for hiding this comment

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

It does work as expected when I do not enable xdist though:

====================================================================================================== test session starts =======================================================================================================
platform darwin -- Python 3.11.6, pytest-7.4.2, pluggy-1.3.0 -- /Users/ogrisel/miniforge3/envs/dev/bin/python3.11
cachedir: .pytest_cache
To reproduce this test run, set the following environment variable:
    SKLEARN_TESTS_GLOBAL_RANDOM_SEED="82"
See: https://scikit-learn.org/dev/computing/parallelism.html#sklearn-tests-global-random-seed
rootdir: /Users/ogrisel/code/scikit-learn
configfile: setup.cfg
plugins: repeat-0.9.2, anyio-4.0.0, cov-4.1.0, xdist-3.3.1
collected 280 items / 278 deselected / 2 selected                                                                                                                                                                                

sklearn/cluster/tests/test_k_means.py::test_sample_weight_init[82-k-means++] PASSED                                                                                                                                        [ 50%]
sklearn/cluster/tests/test_k_means.py::test_sample_weight_init[82-random] PASSED                                                                                                                                           [100%]

=============================================================================================== 2 passed, 278 deselected in 0.17s ================================================================================================

Copy link
Member

Choose a reason for hiding this comment

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

Hum this is what you commented above:

as I mentioned in one of the comment pytest --pyargs sklearn.tests.test_dummy -n2 does not seem to run pytest_configure_node so in the 'any' case we set a fixed random_state (the default value). I don't think this matters that much in practice.

I don't understand why you think it does not matter. The "any" mode is used by our nightly CI to discover unexpected seed-sensitive test failures over time. I think it's important.

As I commented above this is not related to the use of --pyargs: I do not use it in the reproducer.

Copy link
Member Author

@lesteve lesteve Jan 10, 2024

Choose a reason for hiding this comment

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

Thanks for testing, I thought I tested a variety of combinations:

  1. SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' pytest sklearn -n2
  2. SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' pytest --pyargs sklearn -n2
  3. SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' pytest sklearn/tests/test_dummy.py -n2
  4. SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' pytest --pyargs sklearn.tests.test_dummy -n2

and only 4. did not work for some unknown reason, which is why I said it is not that important.

Apparently it is more complicated than this, so I need to check again!

Copy link
Member Author

@lesteve lesteve Mar 19, 2024

Choose a reason for hiding this comment

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

So I fixed the bug I think. I was using getattr(config.workerinput, ...) rather than config.workerinput.get ...

I tested the cases mentioned in my previous comment and now only 4. does not work (as expected).

Copy link
Member Author

@lesteve lesteve Mar 19, 2024

Choose a reason for hiding this comment

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

For completeness, I believe the issue is actually pytest-dev/pytest-xdist#917. pytest_configure is not run in the xdist controller in 4. for some reason so the mechanism to create the random seed in the main controller and access it in the xdist workers does not work.

Copy link
Member

Choose a reason for hiding this comment

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

Would it be possible to generate the seed in each worker instead of having to use the controller?

I am thinking of something like calling the random number generator with a shared seed in each worker. To have a shared seed we need some form of state that is easily accessed by each worker. I don't know if such a thing exists in pytest, maybe each run is assigned some kind of "run ID" or something?

If each worker can derive the correct seed itself we don't need to have to use the controller to coordinate the workers.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

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

Interesting idea, I'll look into it!

Copy link
Member Author

Choose a reason for hiding this comment

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

Thinking about it, you actually want the controller to know about the seed, be it only to display it in pytest_report_header.

I looked a bit at PYTEST_XDIST_TESTRUNUID env variable but it is only available in the workers and not the controller, there is the testrun_uid fixture but I am not sureit can be made to fit nicely with the pytest_generate_tests with metafunc thing.

@lesteve
Copy link
Member Author

lesteve commented Jun 3, 2024

Question from Discord by @thomasjpfan:

We ended up with the current implemention to get --pyargs sklearn.tests.test_dummy to work.

Was the PR updated to work for this use case?

The only thing that does not quite work as well as main is that the following:

SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' pytest --pyargs sklearn.tests.test_dummy -n2 -v

will always use 42 as the random seed. The underlying Pytest issue seems to be pytest-dev/pytest-xdist#917.

Apart from using any anything other settings of the global random seed will work for example:

SKLEARN_TESTS_GLOBAL_RANDOM_SEED='1-10' pytest --pyargs sklearn.tests.test_dummy -n2 -v

I think the edge case is used seldom enough that it is not such an issue compared to the advantages:

  • everything is in conftest.py, rather than having a plugin activated in setup.cfg and also in conftest.py
  • comments have been added to document the edge cases
  • pytest_generate_tests(metafunc) is a neater way to parametrise the test than the closure that was previously used

Copy link
Member

@thomasjpfan thomasjpfan left a comment

Choose a reason for hiding this comment

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

@lesteve I agree this PR is a cleaner solution. In the nightly builds, we set SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' here:

export SKLEARN_TESTS_GLOBAL_RANDOM_SEED="any"

which will be used with --pyargs:

TEST_CMD="$TEST_CMD --pyargs sklearn"

After this PR is merged, will this seed always be 42?

@lesteve
Copy link
Member Author

lesteve commented Jun 23, 2024

@thomasjpfan thanks a lot for taking a look 🙏, this PR is definitely not completely trivial to review! I still think it kind of "simplifies" things a bit, but each time I have a new look, it takes me a bit of time to put things back in my short-term memory 😅 ...

After this PR is merged, will this seed always be 42?

With this PR merged, everything will work the same as previously: the CI will pick a random seed each time.

For example, this does work locally so I this should work in the CI:

SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' pytest --pyargs sklearn -n2 -k test_median_strategy_regressor -v

Here is the ouptut, notice SKLEARN_TESTS_GLOBAL_RANDOM_SEED="31" in the header and the test does use 31: test_median_strategy_regressor[31]:

=========================================================================================================== test session starts ===========================================================================================================
platform linux -- Python 3.12.2, pytest-8.2.2, pluggy-1.5.0 -- /home/lesteve/micromamba/envs/scikit-learn-dev/bin/python
cachedir: .pytest_cache
hypothesis profile 'default' -> database=DirectoryBasedExampleDatabase(PosixPath('/home/lesteve/dev/scikit-learn/.hypothesis/examples'))
To reproduce this test run, set the following environment variable:
    SKLEARN_TESTS_GLOBAL_RANDOM_SEED="31"
See: https://scikit-learn.org/dev/computing/parallelism.html#sklearn-tests-global-random-seed
rootdir: /home/lesteve/dev/scikit-learn
configfile: setup.cfg
plugins: anyio-4.2.0, hypothesis-6.97.3, cov-4.1.0, xdist-3.5.0, repeat-0.9.3
2 workers [1 item]      
scheduling tests via LoadScheduling

sklearn/tests/test_dummy.py::test_median_strategy_regressor[31] 
[gw0] [100%] PASSED sklearn/tests/test_dummy.py::test_median_strategy_regressor[31] 

===================================================================================================== 1 passed, 170 warnings in 7.76s =====================================================================================================

Full disclosure: if you run the same command outside of the scikit-learn root folder, you will be in the pathological case and get SKLEARN_TESTS_GLOBAL_RANDOM_SEED="42".

I have never noticed this, but the current behaviour of main is an error though, so I guess this is not something that we ever do, and this PR is an improvement even in this "not quite perfectly working" case?

The error comes from the fact that random seeds are different in the two workers:

__________________________________________________________________________________________________________ ERROR collecting gw1 ___________________________________________________________________________________________________________
Different tests were collected between gw0 and gw1. The difference is:
--- gw0

+++ gw1

@@ -1 +1 @@

-dev/scikit-learn/sklearn/tests/test_dummy.py::test_median_strategy_regressor[88]
+dev/scikit-learn/sklearn/tests/test_dummy.py::test_median_strategy_regressor[20]

@lesteve
Copy link
Member Author

lesteve commented Jun 23, 2024

Let me try to do some kind of summary to make it easier.

To be explicit, this is when running from scikit-learn root folder, which I think is 90-99% of our usage.

main this PR
full tests + path works works
full tests + --pyargs works works
partial tests + path works works
partial tests + --pyargs works global_random_seed=42

I would argue the partial tests + --pyargs with SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' case is not something we care too much about. Using paths is more convenient because for example you have tab-completion that works.

Here is an explanation of the different cases:

  • full tests with path
    SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' pytest sklearn -n2 -k test_median_strategy_regressor -v
    
  • full tests with --pyargs
    SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' pytest --pyargs sklearn -n2 -k test_median_strategy_regressor -v
    
  • partial tests with path
    SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' pytest sklearn/tests/test_dummy.py -n2 -k test_median_strategy_regressor -v
    
  • partial tests with --pyargs
    SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' pytest --pyargs sklearn.tests.test_dummy -n2 -k test_median_strategy_regressor -v
    

@thomasjpfan
Copy link
Member

In the CI, we do move out of the root directory:

https://github.com/scikit-learn/scikit-learn/blob/a67ebbebc173007735e62eef7878c08435d28d89/build_tools/azure/test_script.sh#L31C1-L31C13

Does this PR work when pytest is not running from the root directory?

@lesteve
Copy link
Member Author

lesteve commented Jun 26, 2024

In the CI, we do move out of the root directory:

Indeed I forgot about this ...

Does this PR work when pytest is not running from the root directory?

No it does not 😓, when not running from the scikit-learn root folder, the global random seed will always be 42.

SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' pytest --pyargs sklearn -n2 -k test_median_strategy_regressor -v

Part of the output (that shows the global random seed is 42):

tests/test_dummy.py::test_median_strategy_regressor[42] 

I am wondering whether it would actually not be a lot simpler to:

  • remove the SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any' functionality. This is the really tricky case where the controller needs to generate the random seed and pass it to the workers.
  • to have the CI try different random seeds, draw a random number between 1 and 100 in bash in the CI and set SKLEARN_TESTS_GLOBAL_RANDOM_SEED accordingly

For visibility, the underlying pytest-xdist limitation seems to be pytest-dev/pytest-xdist#917.

@lesteve
Copy link
Member Author

lesteve commented Jun 26, 2024

In my last commit I implemented what I mentioned in my previous comment. I think by removing the 'any' case, this simplifies the hardest case.

I am wondering whether it would actually not be a lot simpler to:

* remove the `SKLEARN_TESTS_GLOBAL_RANDOM_SEED='any'` functionality. This is the really tricky case where the controller needs to generate the random seed and pass it to the workers.

* to have the CI try different random seeds, draw a random number between 1 and 100 in bash in the CI and set `SKLEARN_TESTS_GLOBAL_RANDOM_SEED` accordingly

@@ -11,7 +11,7 @@ if [[ "$BUILD_REASON" == "Schedule" ]]; then
# Enable global random seed randomization to discover seed-sensitive tests
# only on nightly builds.
# https://scikit-learn.org/stable/computing/parallelism.html#environment-variables
export SKLEARN_TESTS_GLOBAL_RANDOM_SEED="any"
export SKLEARN_TESTS_GLOBAL_RANDOM_SEED=$(($RANDOM % 100))
Copy link
Member

Choose a reason for hiding this comment

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

I like this alternative, it's a lot more intuitive.

Copy link
Member

Choose a reason for hiding this comment

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

@lesteve Can we do a quick "echo" here to show SKLEARN_TESTS_GLOBAL_RANDOM_SEED in the CI output?

Copy link
Member

Choose a reason for hiding this comment

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

We can even move the message of the header here

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

Comment on lines 334 to 346
def pytest_report_header(config):
random_seed_var = environ.get("SKLEARN_TESTS_GLOBAL_RANDOM_SEED")
random_seeds = random_seed_var

return [
"To reproduce this test run, set the following environment variable:",
f' SKLEARN_TESTS_GLOBAL_RANDOM_SEED="{random_seeds}"',
(
"See: https://scikit-learn.org/dev/computing/parallelism.html"
"#sklearn-tests-global-random-seed"
),
]

Copy link
Member

Choose a reason for hiding this comment

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

With this PR, the header doesn't show up in the CI runs anymore. I think it has limited value since we always see the seed that was used in the pytest report of failed tests because it's in the parametrisation of the test. Also I don't think that any contributor has ever needed and even less seen this.

So I think it's fine to just remove this header. We can document the pytest command to run for a given seed in parallelism.rst.

Copy link
Member

@jeremiedbb jeremiedbb left a comment

Choose a reason for hiding this comment

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

LGTM. I would even remove the header report because I don't it useful anymore since it now doesn't show up in the CI anymore, but I'm fine with or without it.

@thomasjpfan thomasjpfan merged commit a4ebe19 into scikit-learn:main Jun 28, 2024
30 checks passed
@lesteve lesteve deleted the simplify-pytest-plugin branch July 1, 2024 10:00
@lesteve
Copy link
Member Author

lesteve commented Jul 1, 2024

Great to see this one merged!

It took some time to get there, but as often through feed-back and discussions, we reached a more robust solution in the end!

@jeremiedbb jeremiedbb mentioned this pull request Jul 2, 2024
11 tasks
snath-xoc pushed a commit to snath-xoc/scikit-learn that referenced this pull request Jul 5, 2024
Co-authored-by: Olivier Grisel <olivier.grisel@ensta.org>
Co-authored-by: Jérémie du Boisberranger <jeremie@probabl.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

BUG: pytest error when loading conftest (seemingly platform-specific)
6 participants