Skip to content

Add maths module for Parameters & DescriptorNumbers with variance and bounds propagation - #329

Open
markbujehein wants to merge 29 commits into
developfrom
add-maths-module
Open

markbujehein wants to merge 29 commits into
developfrom
add-maths-module

Conversation

@markbujehein

Copy link
Copy Markdown
Collaborator

Summary

This adds es.math, a set of maths functions that take a DescriptorNumber or a Parameter and return a new object of the base class. Each result carries:

  • value: evaluated by Scipp, which also validates units.
  • variance: propagated to first order (delta method).
  • fitting bounds: the exact range the function can reach when its argument stays inside its own bounds. Only a Parameter result has bounds.

The functions are:

  • Trig: sin, cos, tan, sinc, arcsin, arccos, arctan, arctan2, plus the aliases asin, acos, atan and atan2.
  • Hyperbolic: sinh, cosh, tanh, arcsinh, arccosh, arctanh, plus the aliases asinh, acosh and atanh.
  • Other: exp, log, log10, sqrt, erf, erfc.

The implementation is in easyscience.variable.math. easyscience.math is a thin re-export, so es.math.sin(angle) works. The names are also exported from easyscience.variable.

Motivation

This work is motivated by the needs discussed in

Design

  • Spec-driven dispatch. Each function is an immutable _MathOp spec (Scipp function, domain, bounds strategy). One dispatcher, _apply, handles all of them, so adding a function means adding a spec rather than new control flow.
  • Input and result types. Every function accepts a DescriptorNumber or a Parameter, including user subclasses.
    • The result is a Parameter if any argument is a Parameter, and a DescriptorNumber otherwise. It is always the base class, never a subclass.
    • A DescriptorNumber argument counts as the point [value, value] when bounds are derived.
    • Unary functions are typed with a TypeVar. arctan2 has three overloads, so mixed calls type-check.
  • Exact bounds. Bounds come from the endpoints and critical points, not a sampled grid. A grid could miss an extremum and make Parameter.from_scipp raise. Degrees are converted to radians for trig bounds.
  • Strict values, permissive bounds.
    • A value outside the function's domain, or a non-finite result, raises ValueError, because it would break a fit loop.
    • Bounds never raise. They are clipped to the domain, infinite bounds are accepted, and bounds that collapse to a single number are padded by a relative 2e-9.
    • tan handles asymptotes per side. For example, [0, 90] deg gives [0, inf), and a lower bound exactly on an asymptote gives -inf.
  • Variance. Scipp handles most functions. erf, erfc and arctan2 use a Jacobian because Scipp doesn't support them. tan rejects values near an asymptote, where the variance would blow up.

Other changes

  • Top-level __all__ fix. easyscience/__init__.py and variable/__init__.py listed the objects themselves, including the __version__ string, instead of their names. As a result, from easyscience import * raised AttributeError. Both now list strings, and variable.__all__ includes math.__all__.
  • es.math and the top-level __all__. math is imported so es.math works, but it is deliberately left out of the top-level __all__. A star import would otherwise shadow the stdlib math. A test covers this.
  • from_scipp return type. It now returns Self (PEP 673) in descriptor_number.py, so calling it on a subclass such as Parameter type-checks.
  • Docs. New docs/api-reference/math.md, nav entry and index entry. All 20 public functions have docstring examples that were executed and cross-checked against independent formulas. The docstrings follow the repo's docstring-lint rules.
  • Lint and format housekeeping, unrelated to the maths code.
    • Docstring-only lint fixes in fitting/sampler.py and fitting/minimizers/minimizer_bumps.py. No code changes.
    • Re-formatting of pixi.toml, pyproject.toml and prettierrc.toml. Whitespace, comment spacing and key layout only.
    • These could be split into their own PR if a smaller diff is preferred.

Tests

  • tests/unit/variable/test_math.py: unit tests for values, bounds, variance, domain errors, units, input and result classes, subclasses, mixed arctan2 arguments, the unit keyword, the guards and the namespace. They are written in the repo's WHEN / THEN / EXPECT style.
  • tests/integration/fitting/test_math_in_fit.py: es.math used inside models (straight line, angle in degrees, quadrature, Gaussian peak with a derived width) across minimizers.
  • A strict xfail documents the known limitation described below.

Known limitations and notes for reviewers

  • Correlations are ignored. Variances assume uncorrelated inputs, because a Parameter stores a variance but no covariance. A function of strongly correlated fit parameters can be off by a large factor. The strict xfail test documents this, and the module docstring explains it.
  • Results are snapshots. A result doesn't follow later changes to its inputs. Inside a model, derive it in __call__, not __init__. The module docstring has a worked example.
  • Open points for the lead developer. These are marked NOTE() and TODO() in math.py:
    • The 1σ thresholds for the tan guards.
    • Snapshot results are not fixed.
    • The cost per evaluation.
    • The hard-coded tolerance in the bounds padding.
  • pixi.lock. 419828c modifies pixi.lock. It looks like the result of running pixi install, so please check whether it should stay in this PR. Seems like Pixi is updating the lock file to a newer format.
  • Out of scope. This PR does not yet address the issues mentioned in:

markbujehein and others added 23 commits October 6, 2026 18:18
…nces as per latest pixi standard (done by `pixi install`)
…ctions

- Update trigonometric functions to accept only the Parameter type.
- Add new inverse trigonometric functions.
- Add TODO comments for inverse trigonometric functions.
Fix inheritance type errors (PEP 673) when invoking from_scipp()
on subclassed objects like Parameter.
- Remove unused imports and commented-out code.
- Standardize function headers and docstrings.
- Add helper functions for bounds derivation to improve error handling.
- Centralize Scipp operations for trig functions (sin, cos, tan, etc.).
- Add a new sinc function with dedicated error handling and docs.
- Protect tangent bounds to prevent variance explosions near asymptotes.
Replace the generic dispatcher with declarative `_MathOp` specs and add
log10, sqrt, erf, erfc and the hyperbolic functions (sinh, cosh, tanh,
arcsinh, arccosh, arctanh).

- Derive exact result bounds from endpoints and critical points instead
  of a sampled grid, which could miss an extremum and make
  Parameter.from_scipp raise
- Convert degrees to radians for trig bounds
- Accept infinite bounds; clip bounds to the function's domain
- Raise ValueError for out-of-domain values and non-finite results
- Propagate variance for erf, erfc and arctan2 via a Jacobian
  (delta method), as Scipp lacks support
- Remove ~500 lines of commented-out code
- Export all math functions from easyscience.variable and make __all__
  a list of strings
- Add unit tests in tests/unit/variable/test_math.py
Give each of the 20 public math functions two examples, using
rad and deg for angle functions and two dimensionless cases (bounds,
variance, clipping or units) for the rest. Outputs were generated by
executing the examples and cross-checked against independent formulas.
Annotate every test in test_math.py with WHEN / THEN / EXPECT comments.
…iance handling

- Replace `lru_cache` with `cache` from `functools` for simpler caching.
- Add constants for mathematical limits and tolerances.
- Enhance error messages for exponential and hyperbolic overflow conditions.
- Enforce checks for finite results during variance propagation in math operations.
- Expand test coverage for `sinc` edge cases and `tan`/`arctan2` variance propagation.
- Mark test for known collapsed bounds bug in `Parameter` (`@pytest.mark.xfail`).
__all__ held the objects themselves (including the value of
__version__) instead of their names. The language reference requires
a sequence of strings, so `from easyscience import *` failed with
AttributeError on the first entry, the version string. Only star
imports were affected, which is why it went unnoticed.

List the names instead, keeping the same set of public objects.

Also import the math submodule and add it to __all__, so that
`es.math` is available.
Remove the math module from easyscience.math to prevent naming conflicts
with Python's built-in math library.
- tan: a bound exactly on an asymptote now makes the result infinite
  on that side only, so [0, 90] deg gives [0, inf) instead of
  (-inf, inf); a lower bound on an asymptote gives -inf
- variable: re-export the math names with `from .math import *` and
  build __all__ from math.__all__ instead of keeping a second list
- math: document how to use es.math inside a model (derive in
  __call__, results are snapshots, inputs are assumed uncorrelated,
  uncertainties depend on the minimizer) and leave NOTE()/TODO()
  remarks for the lead developer (1-sigma guards, non-fixed
  snapshot results, cost per evaluation, hard-coded Para
  tolerance)
- tests: add integration tests that apply es.math to fitted
  parameters (straight line, angle in degrees, quadrature, Gaussian
  peak with a width derived in __call__, across minimizers), a
  strict xfail documenting that correlated parameters are propagated
  as independent, and unit tests for degree variances, tan bounds on
  asymptotes and the package star import not exporting math
Every es.math function now takes a DescriptorNumber or a Parameter (including user
subclasses). The result is a Parameter, with bounds, if any argument is a Parameter,
and a DescriptorNumber otherwise; it is always the base class. A DescriptorNumber
argument counts as the point [value, value] when the bounds are derived.

Unary functions are typed with a TypeVar; arctan2 has three overloads. Tests cover the
new input and result classes, subclasses, mixed arctan2, the unit keyword and the guards.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@markbujehein markbujehein added [scope] enhancement Adds/improves features (major.MINOR.patch) [priority] medium Normal/default priority labels Oct 13, 2026
@codecov

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.05%. Comparing base (511a8e4) to head (db1f696).

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop     #329      +/-   ##
===========================================
+ Coverage    83.24%   84.05%   +0.81%     
===========================================
  Files           63       64       +1     
  Lines         5324     5595     +271     
===========================================
+ Hits          4432     4703     +271     
  Misses         892      892              
Flag Coverage Δ
unittests 84.05% <100.00%> (+0.81%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/easyscience/__init__.py 100.00% <100.00%> (ø)
.../easyscience/fitting/minimizers/minimizer_bumps.py 99.27% <ø> (ø)
src/easyscience/fitting/sampler.py 100.00% <ø> (ø)
src/easyscience/math.py 100.00% <100.00%> (ø)
src/easyscience/variable/__init__.py 100.00% <ø> (ø)
src/easyscience/variable/descriptor_number.py 95.42% <100.00%> (+0.01%) ⬆️

@markbujehein
markbujehein added this pull request to stack #332 October 14, 2026 12:55
@markbujehein markbujehein changed the title feat(math): add es.math, maths functions for Parameters with variance and bounds propagation Add maths module for Parameters & DescriptorNumbers with variance and bounds propagation Oct 14, 2026

@rozyczko rozyczko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Minor comments added. Consider combining both math.py modules into one, as mentioned.

Comment thread src/easyscience/variable/math.py Outdated
Comment thread src/easyscience/variable/__init__.py Outdated
Comment thread src/easyscience/math.py
markbujehein added a commit that referenced this pull request Oct 15, 2026
@markbujehein

Copy link
Copy Markdown
Collaborator Author

@rozyczko fully agree. I just pushed the requested changes and rebased #331 to match

@rozyczko

Copy link
Copy Markdown
Member

@rozyczko fully agree. I just pushed the requested changes and rebased #331 to match

I'm fine with changes, but it would probably be good to ask @damskii9992 to look at it as well

@damskii9992 damskii9992 left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I haven't got very far into this review yet, but here are a bunch of docstrings comments that needs to be adressed. Also, don't add function which aren't supported by Scipp. If they don't have it, we don't have it. Unless we REALLY need it. Instead open an issue in Scipp to ask for these functions.

Here I am referring to erf, erfc and arctan2

My comments below of course also extends to all the other public math functions.

Comment on lines +134 to +135
Self
A new instance of the class this is called on.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The Self typehint does indeed seem to be the proper way to typehint methods that return the class itself or an instance of the class, but it is not really readable in docs, so in our docstrings it should still be the actual name of the class.

global_object.stack.enabled = False


from . import math # noqa: E402

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You don't need this. It doesn't do anything.

Comment thread src/easyscience/math.py
# SPDX-License-Identifier: BSD-3-Clause

"""
Mathematical functions for EasyScience ``Parameter`` objects.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And hopefully DescriptorNumber objects? This one-liner could be more descriptive.

Comment thread src/easyscience/math.py
"""
Mathematical functions for EasyScience ``Parameter`` objects.

Every public function takes one or more ``Parameter`` objects and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"public"? This is the public documentation, all functions here should be public . . .
When you have AI generate docstrings, you should still go through them yourself, as AI tends to be way too verbose and give unnecessary details like this.

Comment thread src/easyscience/math.py
Comment on lines +8 to +72
returns a new ``Parameter`` holding

* the **value**, evaluated by Scipp (which also validates the units),
* the propagated **variance** (first-order "delta method", uncorrelated inputs), and
* the **fitting bounds**, i.e. the exact range the function can take when its
argument stays inside the argument's own bounds.

Conventions:

* *Values are validated strictly.* A value outside the function's domain, or a
non-finite result, raises ``ValueError``; such values would break a fit loop.
* *Bounds are permissive.* They never raise: bounds outside the domain are
clipped to it, infinite bounds are accepted, ``tan`` bounds that cross an
asymptote become ``(-inf, inf)``, and bounds that collapse to (nearly) a single
number are padded by a relative 2e-9 around the value.

Input and result classes:

Every function accepts a ``DescriptorNumber`` or a ``Parameter`` (a
``Parameter`` is a ``DescriptorNumber``), including user subclasses of
either. The result is a ``Parameter``, with bounds, if any argument is a
``Parameter``, and a ``DescriptorNumber``, without bounds, otherwise. It
is always the base class and never the subclass, as for the arithmetic
operators. A ``DescriptorNumber`` argument counts as the single point at
its value when the bounds of a ``Parameter`` result are derived.

Using ``es.math`` in a model:

``es.math`` works on scalar ``Parameter`` objects, so it cannot be
applied over an ``x`` array. Use it to derive scalar quantities, with
their uncertainty, from the parameters of a model or of a fit. Each
result is a new ``Parameter`` that is a *snapshot* of its inputs: it
does not follow later changes. Inside a model, derive it in ``__call__``
so that it is recomputed on every evaluation of the fit, and not once in
``__init__``:

```python
class GaussianPeak(ModelBase):
def sigma(self):
two = self._two
return self._fwhm / (2 * es.math.sqrt(2 * es.math.log(two)))

def __call__(self, x):
sigma = self.sigma().value # derived on every evaluation
shift = (x - self.center.value) ** 2
return self.amplitude.value * np.exp(-shift / (2 * sigma**2))
```

Two things to keep in mind. Propagated variances assume uncorrelated
inputs, because a ``Parameter`` stores a variance but no covariance; a
function of two strongly correlated fit parameters (for example
``arctan2`` of the slope and intercept of a line) can be off by a large
factor. And a fit reports different uncertainties for different
minimizers, so every derived variance is only as good as the variance it
starts from.

Implementation: each function is described by a small, immutable
``_MathOp`` specification (which Scipp function to call, which domain it
accepts, how to derive its bounds, ...). A single dispatcher,
``_apply``, does the work for all of them, so adding a function means
adding a specification, not new control flow.

The public entry point is ``easyscience.math`` (``es.math``), which
re-exports the names in ``__all__``. They are also available from
``easyscience.variable``.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I can't even be bothered to read this. Its WAY too verbose. Please shorten it and write it yourself. Only give details that matter to the user, developers should read elsewhere for details. Having a python example is good, but make it simpler, no need to define classes. Just define 2 parameters and apply 1 or 2 math operations on them.

Comment thread src/easyscience/math.py
Comment on lines +163 to +168
Calculate the exponential, ``e**x``, of a dimensionless parameter.

Parameters
----------
x : _D
Dimensionless input.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And this is not descriptive enough. The first line of a docstring (and here i do mean ONE line, if there is a line-break, its no-longer one line in the docs) is a short summary, a more detailed description then follows.

Comment thread src/easyscience/math.py
Comment on lines +172 to +177
_D
Parameter or DescriptorNumber ``exp(x)`` with propagated
variance and bounds ``(exp(x.min), exp(x.max))``. The class
follows the inputs: a ``Parameter`` (with bounds) if any
argument is one, otherwise a ``DescriptorNumber`` (without
bounds).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And this explanation should be in the general docstring, not in the return description, this one should be shorter. Have you tried to build the docs and look at how this looks? It's inserted into a box in a table, and this will fill that table up way too much . . .

Comment thread src/easyscience/math.py
Comment on lines +186 to +189
If the result overflows (``x`` greater than about 709.78) or,
when ``x`` has a variance, if the propagated variance overflows.
The variance scales with ``exp(2x)``, so that happens from about
355 for a unit variance.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also too much details here, should be in the general docstring.

Comment thread src/easyscience/math.py
Comment on lines +196 to +200
>>> import easyscience as es
>>> x = es.Parameter('x', 1.0, min=0.0, max=2.0)
>>> result = es.math.exp(x)
>>> print(f'{result.value:.4f} in [{result.min:.4f}, {result.max:.4f}]')
2.7183 in [1.0000, 7.3891]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why the terminal syntax? Use:

this_is_python_code = 'with correct syntax highlight'

Comment thread src/easyscience/math.py
>>> import easyscience as es
>>> x = es.Parameter('x', 1.0, min=0.0, max=2.0)
>>> result = es.math.exp(x)
>>> print(f'{result.value:.4f} in [{result.min:.4f}, {result.max:.4f}]')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just print the out parameter directly, it will have its bounds in the repr.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[priority] medium Normal/default priority [scope] enhancement Adds/improves features (major.MINOR.patch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants