From f52dc068b6d05ec9c12583892debdba0b25fa78e Mon Sep 17 00:00:00 2001 From: Bill Hlavacek Date: Sun, 23 Aug 2026 17:39:14 -0600 Subject: [PATCH] Let a differential evolution fit converge on its own objective's scale (#648) The convergence fix in #561 replaced a dimensionless ratio with an absolute objective range, and let an unset de_tolfun go on using stop_tolerance's number so that existing configurations kept the threshold magnitude they had. ADR-0115 justified reading that number as a range by arguing that an absolute range at the same magnitude is a stricter stop. It is stricter only above an objective of 1. Below that it is looser, and without limit, because the two thresholds separate as the objective falls. At the 2e-05 that a well scaled sum of squares fit reaches, a range of 0.002 stops the run at a spread fifty thousand times wider than the ratio of 0.002 it replaced, so the population satisfies it almost as soon as it is scored. What that costs is a wrong answer that looks right. Tutorial lesson 25 fits three pharmacokinetic rates from a single observed curve. It stopped early and reported k_transit as 11.18 against a true 12.76 and k_abs as 11.50 against a true 9.11, at an objective of 2.19e-05 and with the third rate correct to three digits. Those two rates trade against each other, so the early stop fits the data well and nothing in the output looks wrong. Whether de_tolfun was set now decides what the threshold means. An explicit de_tolfun is an absolute range in the objective's own units and is honoured as written, at any sign and any scale, exactly as before. An unset one falls back to stop_tolerance, which keeps the meaning that key has always had: a dimensionless ratio where the objective is positive, applied as max - min <= tol * min so that an all-zero population never divides, and an absolute range only where the objective is not positive and a ratio would mean nothing. So a positive-objective fit converges as it did in 1.7.0, a likelihood fit keeps the #561 fix, and one value of stop_tolerance means the same thing on fits whose objectives are decades apart. Ignoring failed simulations, and the island guard, are untouched. All six of the #561 regression tests use non-positive fitnesses, so every one of them stays on the range branch and passes unchanged. Four new tests cover this defect, each checked by reverting the fix and watching it fail: a positive population whose spread is small absolutely but large relative to the objective is not converged, an explicit de_tolfun is still read as a range, one value gives the same verdict at objectives six decades apart, and a population straddling zero takes the range branch. ADR-0127 records the correction and ADR-0115 carries a note pointing at it. The algorithms page and the two configuration key entries are rewritten, since both described the threshold as a range in every case. Verified on the full suite including the slow and recovery tiers that continuous integration skips: 4772 passed, 13 skipped, none failed. --- CHANGELOG.md | 24 +++++ ...-fit-no-longer-stops-after-generation-0.md | 10 ++- ...ithout-limit-on-any-objective-below-one.md | 86 ++++++++++++++++++ docs/algorithms.rst | 33 ++++--- docs/config_keys.rst | 8 +- .../optimizers/differential_evolution.py | 87 ++++++++++++++----- tests/test_optimizer_integration.py | 61 +++++++++++++ 7 files changed, 271 insertions(+), 38 deletions(-) create mode 100644 docs/adr/0127-the-de-family-fallback-threshold-keeps-the-legacy-keys-ratio-meaning-because-a-ratios-magnitude-read-as-a-range-is-looser-without-limit-on-any-objective-below-one.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c14bb440..4da5876e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -778,6 +778,30 @@ All notable changes to PyBNF are documented below. This project adheres to control off rather than erroring. `sbml_atol` never had the hole, because `parse_atol_setting` has always demanded finiteness. Found while adding the per-model `rtol:` field of #586, which would otherwise have inherited the same check. +- **A differential evolution fit whose objective is small no longer stops before its + parameters have separated (#648, ADR-0127).** The convergence fix below replaced a + dimensionless ratio with an absolute objective range, and let an unset `de_tolfun` keep + using `stop_tolerance`'s number so existing configurations kept the threshold magnitude + they had. ADR-0115 justified that by arguing an absolute range at the same magnitude is a + stricter stop. It is stricter only above an objective of 1. Below that it is looser, and + without limit. At the 2e-05 a well scaled sum of squares fit reaches, a range of 0.002 + stops the run at a spread fifty thousand times wider than the ratio of 0.002 it replaced, + so the population satisfies it almost as soon as it is scored. + What that costs is a wrong answer that looks right. Tutorial lesson 25 fits three + pharmacokinetic rates from one observed curve, and stopped early reporting `k_transit` as + 11.18 against a true 12.76 and `k_abs` as 11.50 against a true 9.11, at an objective of + 2.19e-05 and with the third rate correct to three digits. The two affected rates trade + against each other, so the early stop fits the data well and nothing in the output looks + wrong. + Whether `de_tolfun` was set now decides what the threshold means. An explicit `de_tolfun` + is an absolute range in the objective's own units and is honoured as written, at any sign + and any scale, exactly as it was. An unset one falls back to `stop_tolerance`, which keeps + the meaning that key has always had: a dimensionless ratio where the objective is + positive, applied as `max - min <= tol * min` so an all-zero population never divides, and + an absolute range only where the objective is not positive and a ratio would mean nothing. + So a positive-objective fit converges as it did in 1.7.0, a likelihood fit keeps the fix + below, and one value of `stop_tolerance` means the same thing on fits whose objectives are + decades apart. Ignoring failed simulations, and the island guard, are unchanged. - **`job_type = de` and `job_type = ade` no longer stop after generation 0 on a negative objective (#561, ADR-0115).** The Differential Evolution family tested convergence with a *ratio* of objectives — `max(fit) / min(fit) < 1 + stop_tolerance` — which reads as diff --git a/docs/adr/0115-the-de-family-convergence-test-is-an-absolute-objective-range-over-the-finite-population-carried-by-its-own-key-so-a-likelihood-fit-no-longer-stops-after-generation-0.md b/docs/adr/0115-the-de-family-convergence-test-is-an-absolute-objective-range-over-the-finite-population-carried-by-its-own-key-so-a-likelihood-fit-no-longer-stops-after-generation-0.md index 238f3570..8b646146 100644 --- a/docs/adr/0115-the-de-family-convergence-test-is-an-absolute-objective-range-over-the-finite-population-carried-by-its-own-key-so-a-likelihood-fit-no-longer-stops-after-generation-0.md +++ b/docs/adr/0115-the-de-family-convergence-test-is-an-absolute-objective-range-over-the-finite-population-carried-by-its-own-key-so-a-likelihood-fit-no-longer-stops-after-generation-0.md @@ -126,10 +126,18 @@ population pass — are unaffected. because a restart's arithmetic (range vs window vs tolerance) was otherwise unrecoverable from the log; a DE convergence stop has no such hidden arithmetic. * **The default magnitude.** `de_tolfun` unset is `stop_tolerance` (default `0.002`). - A fit that wants the old *relative* semantics has no equivalent — but that semantics was + A fit that wants the old *relative* semantics has no equivalent -- but that semantics was only ever meaningful for a positive objective, where an absolute range at the same magnitude is a stricter, well-defined stop. + > **Corrected by ADR-0127 (issue #648).** The last clause is wrong. An absolute range at + > the same magnitude is stricter only above an objective of 1, and below it is looser + > without limit: at the 2e-05 a well-scaled sum-of-squares fit reaches, a 0.002 range is + > fifty thousand times looser than the 0.002 ratio it replaced. An unset `de_tolfun` now + > keeps `stop_tolerance`'s ratio meaning where the objective is positive, and reads it as + > a range only where a ratio has none. An explicit `de_tolfun` is unchanged, and so is + > everything else this ADR decided. + ## Consequences * **The DE family runs on a likelihood objective.** On the reported fit the run no longer diff --git a/docs/adr/0127-the-de-family-fallback-threshold-keeps-the-legacy-keys-ratio-meaning-because-a-ratios-magnitude-read-as-a-range-is-looser-without-limit-on-any-objective-below-one.md b/docs/adr/0127-the-de-family-fallback-threshold-keeps-the-legacy-keys-ratio-meaning-because-a-ratios-magnitude-read-as-a-range-is-looser-without-limit-on-any-objective-below-one.md new file mode 100644 index 00000000..855a060f --- /dev/null +++ b/docs/adr/0127-the-de-family-fallback-threshold-keeps-the-legacy-keys-ratio-meaning-because-a-ratios-magnitude-read-as-a-range-is-looser-without-limit-on-any-objective-below-one.md @@ -0,0 +1,86 @@ +# The Differential Evolution family's fallback threshold keeps the legacy key's ratio meaning, because a ratio's magnitude read as a range is looser without limit on any objective below one (issue #648) + +## Status + +Accepted. Corrects one claim in ADR-0115, and changes nothing else it decided. + +## The defect + +ADR-0115 replaced the family's convergence test with an absolute objective range and gave +it a new key, `de_tolfun`. Both were right. It also decided that an unset `de_tolfun` +falls back to `stop_tolerance`, so an existing configuration keeps the number it had, and +justified reading that number as a range like this: + +> A fit that wants the old *relative* semantics has no equivalent — but that semantics was +> only ever meaningful for a positive objective, where an absolute range at the same +> magnitude is a stricter, well-defined stop. + +The claim that the range is stricter is true only above an objective of 1. Below it the +range is looser, and without limit, because the two thresholds diverge as the objective +falls: + +| objective the population reaches | ratio 0.002 stops at a spread of | range 0.002 stops at a spread of | range is | +|---|---|---|---| +| 1000 | 2 | 0.002 | 1000x stricter | +| 1 | 0.002 | 0.002 | the same | +| 2e-05 | 4e-08 | 0.002 | 50000x looser | + +A sum of squares fit on well scaled data lands in the bottom row as a matter of course. +There the population satisfies the stop almost as soon as it is scored, and the run ends +while its parameters are still far apart. + +## Observed + +Tutorial lesson 25 fits a transit compartment pharmacokinetic model, observing only the +central compartment. It reaches an objective of 2.19e-05 and stops there, reporting +`k_transit` as 11.18 against a true 12.76 and `k_abs` as 11.50 against a true 9.11. The two +rates trade against each other, so the fit that stops early fits the data well and reports +a wrong answer with nothing wrong on its face. `k_elim` is recovered to three digits, which +is what makes the failure hard to see. + +The lesson passes at v1.7.0 and at cd6e8531, and fails at 8e006374, which is ADR-0115's +commit. Adding `de_tolfun = 1e-10` to the unchanged lesson recovers all three rates. + +Of the 58 tutorial configurations that use `de` or `ade`, none sets either key, so all 58 +ran on the reinterpreted number. 45 of them are checked for parameter recovery and 44 still +recovered, so the reach is one fit in 45. The reach is narrow because most fits separate +their parameters before the stop can cut them short. It is not narrow by design. + +## The decision + +**Whether `de_tolfun` was set decides what the threshold means.** + +* An **explicit** `de_tolfun` is an absolute range in objective units, chosen by an author + who can see their own objective's scale. It is honoured as written, at any sign and any + scale. ADR-0115's key keeps exactly the meaning it was given. +* An **unset** `de_tolfun` falls back to `stop_tolerance`, and that key keeps the meaning it + has always had. Where the objective is positive it is a dimensionless ratio, applied as + `max - min <= tol * min`. That is the `max / min <= 1 + tol` this family used before #561, + written without the division so an all-zero population cannot divide by zero. Where the + objective is not positive a ratio means nothing, so the number is read as an absolute + range, which is the branch ADR-0115 built and the one #561 needs. + +The test is still assessed over the finite fitnesses only, and the island guard still holds. +Both are ADR-0115's, both were correct, and neither changes here. + +## Why not a different default + +Deriving a range from the objective the population has reached is the same arithmetic as +the ratio, reached by a longer route and with a new number to explain. Choosing a smaller +absolute default only moves the problem: any fixed range is wrong by the ratio of two +objectives' scales, and PyBNF fits objectives spanning many decades. + +Keeping the ratio was rejected wholesale by ADR-0115 because it is the source of #561. It +is not. #561 is a ratio applied where a ratio has no meaning. Applied where it does, a ratio +is the only one of the two forms that gives one value the same meaning on every fit, which +is what a default has to do. + +## Consequences + +* Every fit that does not set `de_tolfun` and whose objective is positive converges exactly + as it did at v1.7.0, with #561's fix to failed simulations retained. +* Every likelihood fit reaching #561's branch is unaffected. All six of ADR-0115's + regression tests use non-positive fitnesses and pass unchanged. +* A configuration that set `de_tolfun` explicitly is unaffected. +* A configuration that set `stop_tolerance` explicitly gets that number read as a ratio + again, which is what it meant when it was written. diff --git a/docs/algorithms.rst b/docs/algorithms.rst index c1ccc64f..65272cca 100644 --- a/docs/algorithms.rst +++ b/docs/algorithms.rst @@ -131,18 +131,27 @@ Convergence """"""""""" A run finishes either at ``max_iterations`` or when the population has converged to -roughly the same objective value. Convergence is measured as an **absolute range** of the -objective over the population: the run stops once the spread between the best and worst -*finite* member falls to ``de_tolfun`` or below. Failed simulations (scored as infinity) -are ignored, so one dead candidate can neither trigger nor block the stop. The range is in -the units of your objective function, which is what makes it well-defined on a likelihood -objective — a negative log-likelihood is unbounded below, so a *ratio* of objectives -(the pre-#561 test) reads an all-negative population as converged and stops after -generation 0. ``de_tolfun`` is unset by default and then follows ``stop_tolerance``, so an -existing config keeps the threshold magnitude it had; set ``de_tolfun`` to control the -objective range independently of that key. In an island run, convergence is assessed only -once every island has completed at least one iteration, so a single finished island cannot -stop the whole search before the others have run. +roughly the same objective value. Convergence is measured over the *finite* members only, +so a failed simulation (scored as infinity) can neither trigger nor block the stop. + +How the threshold is read depends on where it comes from. By default ``de_tolfun`` is +unset and the run uses ``stop_tolerance``, which is a **dimensionless ratio**: on a +positive objective the run stops once the spread between the best and worst member falls +to that fraction of the best member. Reading it that way is what keeps one value +meaningful across fits whose objectives differ by many orders of magnitude. On an +objective that is not positive a ratio means nothing, and reading one there is what made +an all-negative likelihood population look converged and stop the run after generation 0 +before #561, so the same number is read there as an **absolute range** of the objective. + +Setting ``de_tolfun`` states the threshold as an absolute range in your objective's own +units, on every objective. Use it when you know that scale and want to name the spread you +will accept. Do not set it to a value borrowed from ``stop_tolerance``: the two carry +different units, and a ratio's magnitude read as a range is far looser than intended on any +fit whose objective is below 1 (see #648). + +In an island run, convergence is assessed only once every island has completed at least one +iteration, so a single finished island cannot stop the whole search before the others have +run. Applications ^^^^^^^^^^^^ diff --git a/docs/config_keys.rst b/docs/config_keys.rst index 4bf629ac..db76dda7 100644 --- a/docs/config_keys.rst +++ b/docs/config_keys.rst @@ -1934,7 +1934,9 @@ PyBNF offers two versions of :ref:`differential evoltution `: synchronou * ``mutation_factor = 0.7`` **stop_tolerance** - Stop the run when the current population has converged to roughly the same objective function value, measured as an **absolute range** of the objective across the finite members of the population: the run ends once :math:`max\_objective - min\_objective \le e`, where *e* is the value of this key. Failed simulations (which score infinity) are ignored, so one dead candidate can neither trigger nor block the stop. This is a range in the units of your objective function; on a likelihood objective (a negative log-likelihood, which is unbounded below) set it to the smallest population spread you still consider unconverged. Prior to #561 this was a *ratio* test (:math:`max/min < 1 + e`), which stopped the run at generation 0 on any objective that can go negative; see ``de_tolfun`` to set the range independently of this key's magnitude. + Stop the run when the current population has converged to roughly the same objective function value. This key is a **dimensionless ratio**: where the objective is positive, the run ends once the spread between the best and worst finite member falls to :math:`e \times min\_objective` or below, which is the :math:`max/min \le 1 + e` this family has always used. The threshold therefore scales with the objective the population has reached, so the same value means the same thing on a fit whose objective lands at 1e-05 and one whose objective lands at 1000. + + Where the objective is **not** positive a ratio means nothing, and reading one there is what stopped a likelihood fit at generation 0 before #561. On such an objective this key's value is read instead as an absolute range, :math:`max\_objective - min\_objective \le e`. Failed simulations (which score infinity) are ignored either way, so one dead candidate can neither trigger nor block the stop. To state the threshold as an absolute range on every objective, set ``de_tolfun`` instead. Default: 0.002 @@ -1944,7 +1946,9 @@ PyBNF offers two versions of :ref:`differential evoltution `: synchronou **de_tolfun** - The convergence tolerance the run actually uses: the absolute objective range (see ``stop_tolerance``) below which the population counts as converged. It is a range in the units of your objective function, whereas ``stop_tolerance`` was historically a dimensionless ratio; the two are separated so a fit can set a meaningful objective-range stop without reinterpreting the legacy key. Unset, it follows ``stop_tolerance``, so an existing config keeps the threshold magnitude it had. + The convergence tolerance the run actually uses: the absolute objective range (see ``stop_tolerance``) below which the population counts as converged. It is a range in the units of your objective function, whereas ``stop_tolerance`` is a dimensionless ratio. The two are separated so a fit can set a meaningful objective-range stop without reinterpreting the legacy key. Set it when you know your objective's scale and want the run to stop at a spread you can name. + + Unset, the run falls back to ``stop_tolerance`` and reads it the way that key has always been read. Where the objective is positive that is a ratio, so the threshold scales with the objective the population has reached. Where the objective is not positive a ratio means nothing, so the same number is read as an absolute range. Setting this key opts out of that and makes the threshold an absolute range everywhere. Default: unset (follows ``stop_tolerance``) diff --git a/pybnf/algorithms/optimizers/differential_evolution.py b/pybnf/algorithms/optimizers/differential_evolution.py index e6bb4616..03b96267 100644 --- a/pybnf/algorithms/optimizers/differential_evolution.py +++ b/pybnf/algorithms/optimizers/differential_evolution.py @@ -93,8 +93,16 @@ def __init__(self, config): # The convergence tolerance the run actually uses (#561, ADR-0115): an # absolute range in objective units. Unset ``de_tolfun`` falls back to # ``stop_tolerance`` -- the single knob the test used before -- so an existing - # config keeps its threshold magnitude, now read as a range rather than a ratio. + # config keeps its threshold magnitude. + # + # Whether it was set MATTERS, not just what it is (#648, ADR-0127). An explicit + # ``de_tolfun`` is a range the author chose in their own objective's units and is + # always honoured as one. An unset one is the legacy ``stop_tolerance``, which + # was a dimensionless RATIO, and reading a ratio's magnitude as a range is what + # #648 is: on an objective of 2e-05 the ratio stops at a spread of 4e-08 while + # the range stops at 0.002, roughly fifty thousand times looser. configured_tolfun = config.config['de_tolfun'] + self.de_tolfun_is_explicit = configured_tolfun is not None self.de_tolfun = (self.stop_tolerance if configured_tolfun is None else float(configured_tolfun)) @@ -149,31 +157,64 @@ def new_individual(self, individuals, base_index=None): return PSet(new_pset_vars) def _population_converged(self): - """The DE-family convergence test (#561, ADR-0115), shared by ``de`` and ``ade``. - - The population is converged when the spread of its objective values -- - measured as an **absolute range** ``max - min`` over the **finite** fitnesses - only -- has collapsed to within ``de_tolfun``. - - This replaces the original **ratio** test ``max/min < 1 + stop_tolerance``, - which is a convergence statement only when the objective is positive and - bounded below by 0 (a chi-square, an SSE). On a likelihood objective -- a - negative log-likelihood, unbounded below -- an all-negative population lands - the ratio in ``(0, 1]`` and it fires at generation 0 regardless of the spread; - and any single ``inf``-scored failed simulation, paired with a negative - fitness, makes the ratio ``-inf`` and defeats *every* threshold. Both failure - modes are the negative sign, not a real convergence -- so the family was - unrunnable on any estimated-sigma likelihood fit. - - The range form is sign-agnostic. Ignoring the non-finite entries is what makes - a failed simulation unable to either satisfy or defeat the test; it also - subsumes the original ``!= 0`` guard against dividing by an all-zero - population, which ``ade`` never had. An empty finite set (every member still - ``inf``, e.g. before the first result) is never "converged". + """The DE-family convergence test (#561, ADR-0115; #648, ADR-0127), shared by + ``de`` and ``ade``. + + The population is converged when the spread of its objective values, ``max - min`` + over the **finite** fitnesses only, has collapsed to within the threshold. What + that threshold *means* depends on where it came from, because the two sources + carry different units (#648, ADR-0127): + + * An **explicit** ``de_tolfun`` is a range in objective units, chosen by someone + who can see their own objective's scale. It is used exactly as written, on any + sign of objective. + * An **unset** ``de_tolfun`` falls back to ``stop_tolerance``, which has always + been a **dimensionless ratio**. Where the objective is positive it is still read + as one, as a spread relative to the best member: ``max - min <= tol * min``, + which is the ``max / min <= 1 + tol`` this family used before #561, written + without the division. Where the objective is not positive a ratio means nothing, + so the same number is read as an absolute range, which is what #561 needs. + + ADR-0115 justified reading the legacy magnitude as a range by arguing that "an + absolute range at the same magnitude is a stricter, well-defined stop". That holds + only for an objective above 1. Below it the range is *looser*, without limit: at + the 2e-05 a well-scaled sum-of-squares fit reaches, a 0.002 range is fifty thousand + times looser than the 0.002 ratio it replaced, so the population satisfies it long + before the parameters have separated. The fit stops early and reports an excellent + objective at the wrong point (#648). + + What #561 actually found is that the ratio is a convergence statement only when + the objective is positive and bounded below by 0 (a chi-square, an SSE). On a + likelihood objective -- a negative log-likelihood, unbounded below -- an + all-negative population lands the ratio in ``(0, 1]`` and it fires at generation 0 + regardless of the spread; and any single ``inf``-scored failed simulation, paired + with a negative fitness, makes the ratio ``-inf`` and defeats *every* threshold. + Both failure modes are the negative sign, not a real convergence, so the family + was unrunnable on any estimated-sigma likelihood fit. + + ADR-0115 answered that by dropping the ratio everywhere. ADR-0127 narrows the + answer to where the defect is: the ratio is kept where it is well defined, and the + range is used where it is not. Neither reading is dropped, and no fit that worked + under either one changes. + + Ignoring the non-finite entries is what makes a failed simulation unable to either + satisfy or defeat the test, on both branches. Scaling by ``low`` rather than + dividing by it subsumes the original ``!= 0`` guard against an all-zero population, + which ``ade`` never had -- an all-zero population takes the range branch anyway, + since ``low`` is not positive. An empty finite set (every member still ``inf``, + e.g. before the first result) is never "converged". """ finite = np.asarray(self.fitnesses, dtype=float) finite = finite[np.isfinite(finite)] - return bool(finite.size and (finite.max() - finite.min()) <= self.de_tolfun) + if not finite.size: + return False + low = finite.min() + spread = finite.max() - low + if self.de_tolfun_is_explicit or low <= 0.0: + return bool(spread <= self.de_tolfun) + # A positive objective under the legacy ratio: scale the threshold by the best + # member rather than dividing by it, so an all-zero population cannot divide. + return bool(spread <= self.stop_tolerance * low) def start_run(self): return NotImplementedError("start_run() not implemented in DifferentialEvolutionBase class") diff --git a/tests/test_optimizer_integration.py b/tests/test_optimizer_integration.py index 7db50cf1..89abb6b1 100644 --- a/tests/test_optimizer_integration.py +++ b/tests/test_optimizer_integration.py @@ -1393,6 +1393,67 @@ def test_de_tolfun_is_a_knob_of_its_own(tmp_path): assert fallback.de_tolfun == 0.002 +@pytest.mark.parametrize('fit_type', ['de', 'ade']) +def test_an_unset_threshold_scales_with_a_positive_objective(tmp_path, fit_type): + """The #648 defect at its decision point (ADR-0127). + + ``stop_tolerance`` has always been a dimensionless ratio. ADR-0115 kept its magnitude + but read it as an absolute range, on the argument that "an absolute range at the same + magnitude is a stricter, well-defined stop". That is true only above an objective of + 1. This population sits at the 2e-05 a well-scaled sum-of-squares fit reaches, where + the same number is tens of thousands of times looser, so the run stops while its + parameters are still 40 percent apart. + """ + alg = _de_family_alg(tmp_path, fit_type, stop_tolerance=0.002) + _set_fitnesses(alg, fit_type, [2.0e-05, 2.4e-05, 2.8e-05, 2.2e-05]) # spread 8e-06 + # Read as an absolute range the legacy magnitude fires at once -- the defect. + assert (np.max(alg.fitnesses) - np.min(alg.fitnesses)) <= alg.de_tolfun + # Read as the ratio it has always been, it does not: 8e-06 is 40% of the best member. + assert alg._population_converged() is False + + +@pytest.mark.parametrize('fit_type', ['de', 'ade']) +def test_an_explicit_de_tolfun_stays_an_absolute_range(tmp_path, fit_type): + """An explicit ``de_tolfun`` is a range its author chose in their own objective's + units, so it is honoured as written whatever the scale. Only the *unset* fallback + carries ``stop_tolerance``'s ratio meaning (ADR-0127).""" + alg = _de_family_alg(tmp_path, fit_type, stop_tolerance=0.002, de_tolfun=1.0e-3) + _set_fitnesses(alg, fit_type, [2.0e-05, 2.4e-05, 2.8e-05, 2.2e-05]) # spread 8e-06 + assert alg.de_tolfun_is_explicit is True + assert alg._population_converged() is True + # ...and the same population is NOT converged once the explicit range is tightened + # below the spread, so the branch is reading the number rather than ignoring it. + tight = _de_family_alg(tmp_path, fit_type, stop_tolerance=0.002, de_tolfun=1.0e-9) + _set_fitnesses(tight, fit_type, [2.0e-05, 2.4e-05, 2.8e-05, 2.2e-05]) + assert tight._population_converged() is False + + +@pytest.mark.parametrize('fit_type', ['de', 'ade']) +def test_the_unset_threshold_gives_one_verdict_at_every_objective_scale(tmp_path, fit_type): + """The property the ratio form has and an absolute range cannot: the same *relative* + spread is judged the same way at objectives six decades apart. An absolute range + would call the first pair converged at both scales and the second pair converged at + the small scale, which is exactly how #648 loses a fit.""" + alg = _de_family_alg(tmp_path, fit_type, stop_tolerance=0.01) + for scale in (1.0e-05, 1.0e+01): + _set_fitnesses(alg, fit_type, [scale, scale * 1.001]) # 0.1% spread, inside 1% + assert alg._population_converged() is True, scale + _set_fitnesses(alg, fit_type, [scale, scale * 1.10]) # 10% spread, outside 1% + assert alg._population_converged() is False, scale + + +@pytest.mark.parametrize('fit_type', ['de', 'ade']) +def test_a_population_straddling_zero_uses_the_absolute_range(tmp_path, fit_type): + """A ratio is meaningless once the best member is not positive, so the fallback reads + its number as a range there. This is the boundary between the two readings, and it is + also what keeps #561's likelihood fits on the branch ADR-0115 built for them.""" + alg = _de_family_alg(tmp_path, fit_type, stop_tolerance=1.0) + _set_fitnesses(alg, fit_type, [-0.4, 0.5]) # spread 0.9, inside the 1.0 range + assert alg._population_converged() is True + _set_fitnesses(alg, fit_type, [-0.4, 1.5]) # spread 1.9, outside it + assert alg._population_converged() is False + + def test_ade_convergence_survives_an_all_zero_population(tmp_path): """ade never had de's ``!= 0`` guard, so an all-zero population computed max/min = 0/0 = nan (a RuntimeWarning; nan < threshold is False, so it silently