Files
2026-07-13 13:17:40 +08:00

878 lines
28 KiB
Python

import contextlib
import os
import shutil
import sys
import tempfile
import unittest
from copy import deepcopy
from unittest.mock import patch
import numpy as np
import pandas
import pytest
from packaging.version import Version
import ray
from ray import tune
from ray.air.constants import TRAINING_ITERATION
from ray.tune.search import ConcurrencyLimiter
def _invalid_objective(config):
metric = "report"
if config[metric] > 4:
tune.report({"_metric": float("inf")})
elif config[metric] > 3:
tune.report({"_metric": float("-inf")})
elif config[metric] > 2:
tune.report({"_metric": np.nan})
else:
tune.report({"_metric": float(config[metric]) or 0.1})
def _multi_objective(config):
tune.report(dict(a=config["a"] * 100, b=config["b"] * -100, c=config["c"]))
def _dummy_objective(config):
tune.report(dict(metric=config["report"]))
class InvalidValuesTest(unittest.TestCase):
"""
Test searcher handling of invalid values (NaN, -inf, inf).
Implicitly tests automatic config conversion and default (anonymous)
mode handling.
Also tests that searcher save doesn't throw any errors during
experiment checkpointing.
"""
def setUp(self):
self.config = {"report": tune.uniform(0.0, 5.0), "list": [1, 2, 3], "num": 4}
def tearDown(self):
pass
@classmethod
def setUpClass(cls):
ray.init(num_cpus=4, num_gpus=0, include_dashboard=False)
@classmethod
def tearDownClass(cls):
ray.shutdown()
def assertCorrectExperimentOutput(self, analysis):
best_trial = analysis.best_trial
self.assertLessEqual(best_trial.config["report"], 2.0)
# Make sure that constant parameters aren't lost
# Hyperopt converts lists to tuples, so check for either
self.assertIn(best_trial.config["list"], ([1, 2, 3], (1, 2, 3)))
self.assertEqual(best_trial.config["num"], 4)
@contextlib.contextmanager
def check_searcher_checkpoint_errors_scope(self):
buffer = []
from ray.tune.execution.tune_controller import logger
with patch.object(logger, "warning", lambda x: buffer.append(x)):
yield
assert not any(
"Experiment state snapshotting failed: Can't pickle local object" in x
for x in buffer
), "Searcher checkpointing failed (unable to serialize)."
def testAxManualSetup(self):
from ax.service.ax_client import AxClient, ObjectiveProperties
from ray.tune.search.ax import AxSearch
config = self.config.copy()
config["mixed_list"] = [1, tune.uniform(2, 3), 4]
converted_config = AxSearch.convert_search_space(config)
# At least one nan, inf, -inf and float
client = AxClient(random_seed=4321)
client.create_experiment(
parameters=converted_config,
objectives={"_metric": ObjectiveProperties(minimize=False)},
)
searcher = AxSearch(ax_client=client)
out = tune.run(
_invalid_objective,
search_alg=searcher,
metric="_metric",
mode="max",
num_samples=4,
reuse_actors=False,
)
self.assertCorrectExperimentOutput(out)
self.assertEqual(out.best_trial.config["mixed_list"][0], 1)
self.assertGreaterEqual(out.best_trial.config["mixed_list"][1], 2)
self.assertLess(out.best_trial.config["mixed_list"][1], 3)
self.assertEqual(out.best_trial.config["mixed_list"][2], 4)
def testAx(self):
from ray.tune.search.ax import AxSearch
searcher = ConcurrencyLimiter(AxSearch(random_seed=4321), max_concurrent=2)
with self.check_searcher_checkpoint_errors_scope():
# Make sure enough samples are used so that Ax actually fits a model
# for config suggestion
out = tune.run(
_invalid_objective,
search_alg=searcher,
metric="_metric",
mode="max",
num_samples=16,
reuse_actors=False,
config=self.config,
)
self.assertCorrectExperimentOutput(out)
def testBayesOpt(self):
from ray.tune.search.bayesopt import BayesOptSearch
with self.check_searcher_checkpoint_errors_scope():
out = tune.run(
_invalid_objective,
# At least one nan, inf, -inf and float
search_alg=BayesOptSearch(random_state=1234),
config=self.config,
metric="_metric",
mode="max",
num_samples=8,
reuse_actors=False,
)
self.assertCorrectExperimentOutput(out)
@pytest.mark.skipif(
sys.version_info >= (3, 12),
reason="BOHB not yet supported for python 3.12+",
)
def testBOHB(self):
from ray.tune.search.bohb import TuneBOHB
with self.check_searcher_checkpoint_errors_scope():
out = tune.run(
_invalid_objective,
search_alg=TuneBOHB(seed=1000),
config=self.config,
metric="_metric",
mode="max",
num_samples=8,
reuse_actors=False,
)
self.assertCorrectExperimentOutput(out)
@pytest.mark.skipif(
sys.version_info >= (3, 12), reason="HEBO doesn't support py312"
)
def testHEBO(self):
if Version(pandas.__version__) >= Version("2.0.0"):
pytest.skip("HEBO does not support pandas>=2.0.0")
from ray.tune.search.hebo import HEBOSearch
with self.check_searcher_checkpoint_errors_scope():
out = tune.run(
_invalid_objective,
# At least one nan, inf, -inf and float
search_alg=HEBOSearch(random_state_seed=123),
config=self.config,
metric="_metric",
mode="max",
num_samples=8,
reuse_actors=False,
)
self.assertCorrectExperimentOutput(out)
def testHyperopt(self):
from ray.tune.search.hyperopt import HyperOptSearch
with self.check_searcher_checkpoint_errors_scope():
out = tune.run(
_invalid_objective,
# At least one nan, inf, -inf and float
search_alg=HyperOptSearch(random_state_seed=1234),
config=self.config,
metric="_metric",
mode="max",
num_samples=8,
reuse_actors=False,
)
self.assertCorrectExperimentOutput(out)
def testNevergrad(self):
import nevergrad as ng
from ray.tune.search.nevergrad import NevergradSearch
np.random.seed(2020) # At least one nan, inf, -inf and float
with self.check_searcher_checkpoint_errors_scope():
out = tune.run(
_invalid_objective,
search_alg=NevergradSearch(optimizer=ng.optimizers.RandomSearch),
config=self.config,
mode="max",
num_samples=16,
reuse_actors=False,
)
self.assertCorrectExperimentOutput(out)
def testNevergradWithRequiredOptimizerKwargs(self):
import nevergrad as ng
from ray.tune.search.nevergrad import NevergradSearch
NevergradSearch(optimizer=ng.optimizers.CM, optimizer_kwargs=dict(budget=16))
def testOptuna(self):
from optuna.samplers import RandomSampler
from ray.tune.search.optuna import OptunaSearch
np.random.seed(1000) # At least one nan, inf, -inf and float
with self.check_searcher_checkpoint_errors_scope():
out = tune.run(
_invalid_objective,
search_alg=OptunaSearch(sampler=RandomSampler(seed=1234), storage=None),
config=self.config,
metric="_metric",
mode="max",
num_samples=8,
reuse_actors=False,
)
self.assertCorrectExperimentOutput(out)
def testOptunaWithStorage(self):
from optuna.samplers import RandomSampler
from optuna.storages import JournalStorage
from optuna.storages.journal import JournalFileBackend
from ray.tune.search.optuna import OptunaSearch
np.random.seed(1000) # At least one nan, inf, -inf and float
storage_file_path = "/tmp/my_test_study.log"
with self.check_searcher_checkpoint_errors_scope():
out = tune.run(
_invalid_objective,
search_alg=OptunaSearch(
sampler=RandomSampler(seed=1234),
study_name="my_test_study",
storage=JournalStorage(
JournalFileBackend(file_path=storage_file_path)
),
),
config=self.config,
metric="_metric",
mode="max",
num_samples=8,
reuse_actors=False,
)
self.assertCorrectExperimentOutput(out)
self.assertTrue(os.path.exists(storage_file_path))
def testOptunaReportTooOften(self):
from optuna.samplers import RandomSampler
from ray.tune.search.optuna import OptunaSearch
searcher = OptunaSearch(
sampler=RandomSampler(seed=1234),
space=OptunaSearch.convert_search_space(self.config),
metric="metric",
mode="max",
)
searcher.suggest("trial_1")
searcher.on_trial_result("trial_1", {"training_iteration": 1, "metric": 1})
searcher.on_trial_complete("trial_1", {"training_iteration": 2, "metric": 1})
# Report after complete should not fail
searcher.on_trial_result("trial_1", {"training_iteration": 3, "metric": 1})
searcher.on_trial_complete("trial_1", {"training_iteration": 4, "metric": 1})
def testZOOpt(self):
self.skipTest(
"Recent ZOOpt versions fail handling invalid values gracefully. "
"Skipping until a fix is added in a future ZOOpt release."
)
from ray.tune.search.zoopt import ZOOptSearch
# This seed tests that a nan result doesn't cause an error if it shows
# up after the initial data collection phase.
np.random.seed(1002) # At least one nan, inf, -inf and float
with self.check_searcher_checkpoint_errors_scope():
out = tune.run(
_invalid_objective,
search_alg=ZOOptSearch(budget=25, parallel_num=4),
config=self.config,
metric="_metric",
mode="max",
num_samples=16,
reuse_actors=False,
)
self.assertCorrectExperimentOutput(out)
class AddEvaluatedPointTest(unittest.TestCase):
"""
Test add_evaluated_point method in searchers that support it.
"""
def setUp(self):
self.param_name = "report"
self.valid_value = 1.0
self.space = {self.param_name: tune.uniform(0.0, 5.0)}
self.analysis = tune.run(
_dummy_objective,
config=self.space,
metric="metric",
num_samples=4,
verbose=0,
)
def tearDown(self):
pass
@classmethod
def setUpClass(cls):
ray.init(num_cpus=4, num_gpus=0, include_dashboard=False)
@classmethod
def tearDownClass(cls):
ray.shutdown()
def run_add_evaluated_point(self, point, searcher, get_len_X, get_len_y):
searcher = deepcopy(searcher)
len_X = get_len_X(searcher)
len_y = get_len_y(searcher)
self.assertEqual(len_X, 0)
self.assertEqual(len_y, 0)
searcher.add_evaluated_point(point, 1.0)
len_X = get_len_X(searcher)
len_y = get_len_y(searcher)
self.assertEqual(len_X, 1)
self.assertEqual(len_y, 1)
searcher.suggest("1")
def run_add_evaluated_trials(self, searcher, get_len_X, get_len_y):
searcher_copy = deepcopy(searcher)
searcher_copy.add_evaluated_trials(self.analysis, "metric")
self.assertEqual(get_len_X(searcher_copy), 4)
self.assertEqual(get_len_y(searcher_copy), 4)
searcher_copy.suggest("1")
searcher_copy = deepcopy(searcher)
searcher_copy.add_evaluated_trials(self.analysis.trials, "metric")
self.assertEqual(get_len_X(searcher_copy), 4)
self.assertEqual(get_len_y(searcher_copy), 4)
searcher_copy.suggest("1")
searcher_copy = deepcopy(searcher)
searcher_copy.add_evaluated_trials(self.analysis.trials[0], "metric")
self.assertEqual(get_len_X(searcher_copy), 1)
self.assertEqual(get_len_y(searcher_copy), 1)
searcher_copy.suggest("1")
def testOptuna(self):
from optuna.storages import JournalStorage
from optuna.storages.journal import JournalFileBackend
from optuna.trial import TrialState
from ray.tune.search.optuna import OptunaSearch
# OptunaSearch with in-memory storage
searcher = OptunaSearch(
space=self.space,
storage=None,
metric="metric",
mode="max",
points_to_evaluate=[{self.param_name: self.valid_value}],
evaluated_rewards=[1.0],
)
get_len = lambda s: len(s._ot_study.trials) # noqa E731
self.assertGreater(get_len(searcher), 0)
# OptunaSearch with external storage
storage_file_path = "/tmp/my_test_study.log"
searcher = OptunaSearch(
space=self.space,
study_name="my_test_study",
storage=JournalStorage(JournalFileBackend(file_path=storage_file_path)),
metric="metric",
mode="max",
points_to_evaluate=[{self.param_name: self.valid_value}],
evaluated_rewards=[1.0],
)
get_len = lambda s: len(s._ot_study.trials) # noqa E731
self.assertGreater(get_len(searcher), 0)
self.assertTrue(os.path.exists(storage_file_path))
searcher = OptunaSearch(
space=self.space,
metric="metric",
mode="max",
)
point = {
self.param_name: self.valid_value,
}
self.assertEqual(get_len(searcher), 0)
searcher.add_evaluated_point(point, 1.0, intermediate_values=[0.8, 0.9])
self.assertEqual(get_len(searcher), 1)
self.assertTrue(searcher._ot_study.trials[-1].state == TrialState.COMPLETE)
searcher.add_evaluated_point(
point, 1.0, intermediate_values=[0.8, 0.9], error=True
)
self.assertEqual(get_len(searcher), 2)
self.assertTrue(searcher._ot_study.trials[-1].state == TrialState.FAIL)
searcher.add_evaluated_point(
point, 1.0, intermediate_values=[0.8, 0.9], pruned=True
)
self.assertEqual(get_len(searcher), 3)
self.assertTrue(searcher._ot_study.trials[-1].state == TrialState.PRUNED)
searcher.suggest("1")
searcher = OptunaSearch(
space=self.space,
metric="metric",
mode="max",
)
self.run_add_evaluated_trials(searcher, get_len, get_len)
def dbr_space(trial):
return {self.param_name: trial.suggest_float(self.param_name, 0.0, 5.0)}
dbr_searcher = OptunaSearch(
space=dbr_space,
metric="metric",
mode="max",
)
with self.assertRaises(TypeError):
dbr_searcher.add_evaluated_point(point, 1.0)
@pytest.mark.skipif(
sys.version_info >= (3, 12), reason="HEBO doesn't support py312"
)
def testHEBO(self):
if Version(pandas.__version__) >= Version("2.0.0"):
pytest.skip("HEBO does not support pandas>=2.0.0")
from ray.tune.search.hebo import HEBOSearch
searcher = HEBOSearch(
space=self.space,
metric="metric",
mode="max",
)
point = {
self.param_name: self.valid_value,
}
get_len_X = lambda s: len(s._opt.X) # noqa E731
get_len_y = lambda s: len(s._opt.y) # noqa E731
self.run_add_evaluated_point(point, searcher, get_len_X, get_len_y)
self.run_add_evaluated_trials(searcher, get_len_X, get_len_y)
class SaveRestoreCheckpointTest(unittest.TestCase):
"""
Test searcher save and restore functionality.
"""
def setUp(self):
self.tempdir = tempfile.mkdtemp()
self.checkpoint_path = os.path.join(self.tempdir, "checkpoint.pkl")
self.metric_name = "metric"
self.config = {"a": tune.uniform(0.0, 5.0)}
def tearDown(self):
shutil.rmtree(self.tempdir)
@classmethod
def setUpClass(cls):
ray.init(num_cpus=4, num_gpus=0, include_dashboard=False)
@classmethod
def tearDownClass(cls):
ray.shutdown()
def _on_trial_callbacks(self, searcher, trial_id):
result = {
TRAINING_ITERATION: 1,
self.metric_name: 1,
"config/a": 1.0,
"time_total_s": 1,
}
searcher.on_trial_result(trial_id, result)
searcher.on_trial_complete(trial_id, result)
def _save(self, searcher):
searcher.set_search_properties(
metric=self.metric_name, mode="max", config=self.config
)
searcher.suggest("1")
searcher.suggest("2")
searcher.suggest("not_completed")
self._on_trial_callbacks(searcher, "1")
searcher.save(self.checkpoint_path)
def _restore(self, searcher):
# Restoration shouldn't require another call to `searcher.set_search_properties`
searcher.restore(self.checkpoint_path)
self._on_trial_callbacks(searcher, "2")
searcher.suggest("3")
self._on_trial_callbacks(searcher, "3")
# NOTE: Trial "not_completed" that was suggested before saving never completes
# We expect that it should still be tracked in the searcher state,
# which is usually done in the searcher's `_live_trial_mapping`.
# See individual searcher tests below for the special cases (e.g. Optuna, BOHB).
if hasattr(searcher, "_live_trial_mapping"):
assert "not_completed" in searcher._live_trial_mapping
def testAx(self):
from ax.service.ax_client import AxClient, ObjectiveProperties
from ray.tune.search.ax import AxSearch
converted_config = AxSearch.convert_search_space(self.config)
client = AxClient()
client.create_experiment(
parameters=converted_config,
objectives={self.metric_name: ObjectiveProperties(minimize=False)},
)
searcher = AxSearch(ax_client=client)
self._save(searcher)
client = AxClient()
client.create_experiment(
parameters=converted_config,
objectives={self.metric_name: ObjectiveProperties(minimize=False)},
)
searcher = AxSearch(ax_client=client)
self._restore(searcher)
def testBayesOpt(self):
from ray.tune.search.bayesopt import BayesOptSearch
searcher = BayesOptSearch(
space=self.config, metric=self.metric_name, mode="max"
)
self._save(searcher)
searcher = BayesOptSearch()
self._restore(searcher)
@pytest.mark.skipif(
sys.version_info >= (3, 12),
reason="BOHB not yet supported for python 3.12+",
)
def testBOHB(self):
from ray.tune.search.bohb import TuneBOHB
searcher = TuneBOHB(space=self.config, metric=self.metric_name, mode="max")
self._save(searcher)
searcher = TuneBOHB()
self._restore(searcher)
assert "not_completed" in searcher.trial_to_params
@pytest.mark.skipif(
sys.version_info >= (3, 12), reason="HEBO doesn't support py312"
)
def testHEBO(self):
if Version(pandas.__version__) >= Version("2.0.0"):
pytest.skip("HEBO does not support pandas>=2.0.0")
from ray.tune.search.hebo import HEBOSearch
searcher = HEBOSearch(
space=self.config,
metric=self.metric_name,
mode="max",
random_state_seed=1234,
)
self._save(searcher)
searcher = HEBOSearch()
self._restore(searcher)
def testHyperopt(self):
from ray.tune.search.hyperopt import HyperOptSearch
searcher = HyperOptSearch(
space=self.config,
metric=self.metric_name,
mode="max",
)
self._save(searcher)
searcher = HyperOptSearch()
self._restore(searcher)
def testNevergrad(self):
import nevergrad as ng
from ray.tune.search.nevergrad import NevergradSearch
searcher = NevergradSearch(
space=self.config,
metric=self.metric_name,
mode="max",
optimizer=ng.optimizers.RandomSearch,
)
self._save(searcher)
# `optimizer` is the only required argument
searcher = NevergradSearch(optimizer=ng.optimizers.RandomSearch)
self._restore(searcher)
def testOptuna(self):
from ray.tune.search.optuna import OptunaSearch
searcher = OptunaSearch(
space=self.config,
storage=None,
metric=self.metric_name,
mode="max",
)
self._save(searcher)
searcher = OptunaSearch()
self._restore(searcher)
assert "not_completed" in searcher._ot_trials
def testOptunaWithStorage(self):
from optuna.storages import JournalStorage
from optuna.storages.journal import JournalFileBackend
from ray.tune.search.optuna import OptunaSearch
storage_file_path = "/tmp/my_test_study.log"
searcher = OptunaSearch(
space=self.config,
study_name="my_test_study",
storage=JournalStorage(JournalFileBackend(file_path=storage_file_path)),
metric=self.metric_name,
mode="max",
)
self._save(searcher)
searcher = OptunaSearch()
self._restore(searcher)
assert "not_completed" in searcher._ot_trials
self.assertTrue(os.path.exists(storage_file_path))
def testZOOpt(self):
from ray.tune.search.zoopt import ZOOptSearch
searcher = ZOOptSearch(
space=self.config,
metric=self.metric_name,
mode="max",
budget=100,
parallel_num=4,
)
self._save(searcher)
# `budget` is the only required argument - will get replaced on restore
searcher = ZOOptSearch(budget=0)
self._restore(searcher)
assert searcher._budget == 100
class MultiObjectiveTest(unittest.TestCase):
"""
Test multi-objective optimization in searchers that support it.
"""
def setUp(self):
self.config = {
"a": tune.uniform(0, 1),
"b": tune.uniform(0, 1),
"c": tune.uniform(0, 1),
}
def tearDown(self):
pass
@classmethod
def setUpClass(cls):
ray.init(num_cpus=4, num_gpus=0, include_dashboard=False)
@classmethod
def tearDownClass(cls):
ray.shutdown()
def testOptuna(self):
from optuna.samplers import RandomSampler
from ray.tune.search.optuna import OptunaSearch
np.random.seed(1000)
out = tune.run(
_multi_objective,
search_alg=OptunaSearch(
sampler=RandomSampler(seed=1234),
storage=None,
metric=["a", "b", "c"],
mode=["max", "min", "max"],
),
config=self.config,
num_samples=16,
reuse_actors=False,
)
best_trial_a = out.get_best_trial("a", "max")
self.assertGreaterEqual(best_trial_a.config["a"], 0.8)
best_trial_b = out.get_best_trial("b", "min")
self.assertGreaterEqual(best_trial_b.config["b"], 0.8)
best_trial_c = out.get_best_trial("c", "max")
self.assertGreaterEqual(best_trial_c.config["c"], 0.8)
def testOptunaWithStorage(self):
from optuna.samplers import RandomSampler
from optuna.storages import JournalStorage
from optuna.storages.journal import JournalFileBackend
from ray.tune.search.optuna import OptunaSearch
np.random.seed(1000)
storage_file_path = "/tmp/my_test_study.log"
out = tune.run(
_multi_objective,
search_alg=OptunaSearch(
sampler=RandomSampler(seed=1234),
study_name="my_test_study",
storage=JournalStorage(JournalFileBackend(file_path=storage_file_path)),
metric=["a", "b", "c"],
mode=["max", "min", "max"],
),
config=self.config,
num_samples=16,
reuse_actors=False,
)
best_trial_a = out.get_best_trial("a", "max")
self.assertGreaterEqual(best_trial_a.config["a"], 0.8)
best_trial_b = out.get_best_trial("b", "min")
self.assertGreaterEqual(best_trial_b.config["b"], 0.8)
best_trial_c = out.get_best_trial("c", "max")
self.assertGreaterEqual(best_trial_c.config["c"], 0.8)
self.assertTrue(os.path.exists(storage_file_path))
class BayesOptHashPrecisionTest(unittest.TestCase):
def testDictHashPrecisionDistinguishesNearFloats(self):
from ray.tune.search.bayesopt.bayesopt_search import _dict_hash
a = {"lr": 1.00001e-05}
b = {"lr": 1.46532e-05}
# The default precision of 5 rounds both to the same string, so the
# two distinct configs collide and one suggestion would be skipped.
self.assertEqual(_dict_hash(a, 5), _dict_hash(b, 5))
# A higher precision keeps them apart.
self.assertNotEqual(_dict_hash(a, 16), _dict_hash(b, 16))
def testRepeatFloatPrecisionIsConfigurable(self):
pytest.importorskip("bayes_opt")
from ray.tune.search.bayesopt import BayesOptSearch
# Default stays at 5 for backward compatibility.
self.assertEqual(BayesOptSearch().repeat_float_precision, 5)
searcher = BayesOptSearch(repeat_float_precision=16)
self.assertEqual(searcher.repeat_float_precision, 16)
def testInvalidRepeatFloatPrecisionRaises(self):
pytest.importorskip("bayes_opt")
from ray.tune.search.bayesopt import BayesOptSearch
with self.assertRaises(ValueError):
BayesOptSearch(repeat_float_precision=-1)
with self.assertRaises(TypeError):
BayesOptSearch(repeat_float_precision="5")
with self.assertRaises(TypeError):
BayesOptSearch(repeat_float_precision=5.5)
with self.assertRaises(TypeError):
BayesOptSearch(repeat_float_precision=True)
class BayesOptConvergenceWarningTest(unittest.TestCase):
def testWarnsAndStopsOnConvergence(self):
"""BayesOptSearch should warn (not silently stop) when it converges."""
from ray.tune.search import Searcher
from ray.tune.search.bayesopt import BayesOptSearch
space = {"width": tune.uniform(0, 20), "height": tune.uniform(-100, 100)}
# patience=1 -> the search stops as soon as a config first repeats,
# making convergence deterministic and quick to reach.
searcher = BayesOptSearch(
space=space, metric="loss", mode="min", random_state=42, patience=1
)
logger_name = "ray.tune.search.bayesopt.bayesopt_search"
finished = False
with self.assertLogs(logger_name, level="WARNING") as cm:
for i in range(50):
config = searcher.suggest(f"trial_{i}")
if config == Searcher.FINISHED:
finished = True
break
if config is None:
continue
searcher.on_trial_complete(
f"trial_{i}", {"loss": config["width"] + config["height"]}
)
self.assertTrue(finished, "BayesOptSearch should finish once converged")
self.assertTrue(
any("stopping early" in msg for msg in cm.output),
f"Expected a convergence warning, got: {cm.output}",
)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))